Skip to content

Commit 1abd876

Browse files
committed
feat: user agreement api
1 parent 3b51154 commit 1abd876

File tree

11 files changed

+361
-27
lines changed

11 files changed

+361
-27
lines changed

openedx/core/djangoapps/agreements/admin.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
from django.contrib import admin
6-
from openedx.core.djangoapps.agreements.models import IntegritySignature
6+
from openedx.core.djangoapps.agreements.models import IntegritySignature, UserAgreement
77
from openedx.core.djangoapps.agreements.models import LTIPIITool
88
from openedx.core.djangoapps.agreements.models import LTIPIISignature
99
from openedx.core.djangoapps.agreements.models import ProctoringPIISignature
@@ -62,3 +62,17 @@ class Meta:
6262

6363

6464
admin.site.register(ProctoringPIISignature, ProctoringPIISignatureAdmin)
65+
66+
67+
class UserAgreementAdmin(admin.ModelAdmin):
68+
"""
69+
Admin for the UserAgreement Model
70+
"""
71+
72+
list_display = ('type', 'name', 'url', 'created', 'updated')
73+
74+
class Meta:
75+
model = UserAgreement
76+
77+
78+
admin.site.register(UserAgreement, UserAgreementAdmin)

openedx/core/djangoapps/agreements/api.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,18 +240,17 @@ def _user_signature_out_of_date(username, course_id):
240240
return user_lti_pii_signature_hash != course_lti_pii_tools_hash
241241

242242

243-
def get_user_agreements(user: User) -> Iterable[UserAgreementRecordData]:
243+
def get_user_agreement_records(user: User) -> Iterable[UserAgreementRecordData]:
244244
"""
245245
Retrieves all the agreements that the specified user has acknowledged.
246246
"""
247-
for agreement_record in UserAgreementRecord.objects.filter(user=user):
247+
for agreement_record in UserAgreementRecord.objects.filter(user=user).select_related("agreement"):
248248
yield UserAgreementRecordData.from_model(agreement_record)
249249

250250

251251
def get_latest_user_agreement_record(
252252
user: User,
253253
agreement_type: str,
254-
agreed_after: datetime = None,
255254
) -> Optional[UserAgreementRecordData]:
256255
"""
257256
Retrieve the user agreement record for the specified user and agreement type.
@@ -262,10 +261,8 @@ def get_latest_user_agreement_record(
262261
try:
263262
record_query = UserAgreementRecord.objects.filter(
264263
user=user,
265-
agreement_type=agreement_type,
264+
agreement__type=agreement_type,
266265
)
267-
if agreed_after:
268-
record_query = record_query.filter(timestamp__gte=agreed_after)
269266
record = record_query.latest("timestamp")
270267
return UserAgreementRecordData.from_model(record)
271268
except UserAgreementRecord.DoesNotExist:
@@ -279,7 +276,7 @@ def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreeme
279276
"""
280277
record = UserAgreementRecord.objects.create(
281278
user=user,
282-
agreement_type=agreement_type,
279+
agreement__type=agreement_type,
283280
timestamp=datetime.now(),
284281
)
285282
return UserAgreementRecordData.from_model(record)

openedx/core/djangoapps/agreements/data.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import attr
88

9-
from .models import UserAgreementRecord
9+
from .models import UserAgreementRecord, UserAgreement
1010

1111

1212
@attr.s(frozen=True, auto_attribs=True)
@@ -28,6 +28,28 @@ class LTIPIISignatureData:
2828
lti_tools_hash: str
2929

3030

31+
32+
@dataclass
33+
class UserAgreementData:
34+
"""
35+
Data for a user agreement record.
36+
"""
37+
type: str
38+
name: str
39+
summary: str
40+
text: str|None
41+
url: str|None
42+
43+
@classmethod
44+
def from_model(cls, model: UserAgreement):
45+
return UserAgreementData(
46+
type=model.type,
47+
name=model.name,
48+
summary=model.summary,
49+
text=model.text,
50+
url=model.url
51+
)
52+
3153
@dataclass
3254
class UserAgreementRecordData:
3355
"""
@@ -36,11 +58,13 @@ class UserAgreementRecordData:
3658
username: str
3759
agreement_type: str
3860
accepted_at: datetime
61+
is_current: bool = True
3962

4063
@classmethod
4164
def from_model(cls, model: UserAgreementRecord):
4265
return UserAgreementRecordData(
4366
username=model.user.username,
44-
agreement_type=model.agreement_type,
67+
agreement_type=model.agreement.type,
4568
accepted_at=model.timestamp,
69+
is_current=model.agreement.updated < model.timestamp
4670
)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Generated by Django 5.2.10 on 2026-01-26 10:21
2+
3+
import django.db.models.deletion
4+
import simple_history.models
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('agreements', '0006_useragreementrecord'),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='HistoricalUserAgreement',
19+
fields=[
20+
('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
21+
('type', models.CharField(db_index=True, max_length=255)),
22+
('name', models.CharField(help_text='Human-readable name for the agreement type. Will be displayed to users in alert to accept the agreement.', max_length=255)),
23+
('summary', models.TextField(help_text='Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.', max_length=1024)),
24+
('text', models.TextField(blank=True, help_text='Full text of the agreement. (Required if url is not provided)', null=True)),
25+
('url', models.URLField(blank=True, help_text='URL where the full agreement can be accessed. Will be used for "Learn More" link in alert to accept the agreement.', null=True)),
26+
('created', models.DateTimeField(blank=True, editable=False)),
27+
('updated', models.DateTimeField(help_text='Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again.')),
28+
('history_id', models.AutoField(primary_key=True, serialize=False)),
29+
('history_date', models.DateTimeField()),
30+
('history_change_reason', models.CharField(max_length=100, null=True)),
31+
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
32+
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
33+
],
34+
options={
35+
'verbose_name': 'historical user agreement',
36+
'verbose_name_plural': 'historical user agreements',
37+
'ordering': ('-history_date', '-history_id'),
38+
'get_latest_by': ('history_date', 'history_id'),
39+
},
40+
bases=(simple_history.models.HistoricalChanges, models.Model),
41+
),
42+
migrations.CreateModel(
43+
name='UserAgreement',
44+
fields=[
45+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
46+
('type', models.CharField(max_length=255, unique=True)),
47+
('name', models.CharField(help_text='Human-readable name for the agreement type. Will be displayed to users in alert to accept the agreement.', max_length=255)),
48+
('summary', models.TextField(help_text='Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.', max_length=1024)),
49+
('text', models.TextField(blank=True, help_text='Full text of the agreement. (Required if url is not provided)', null=True)),
50+
('url', models.URLField(blank=True, help_text='URL where the full agreement can be accessed. Will be used for "Learn More" link in alert to accept the agreement.', null=True)),
51+
('created', models.DateTimeField(auto_now_add=True)),
52+
('updated', models.DateTimeField(help_text='Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again.')),
53+
],
54+
options={
55+
'constraints': [models.CheckConstraint(condition=models.Q(('text__isnull', False), ('url__isnull', False), _connector='OR'), name='agreement_has_text_or_url')],
56+
},
57+
),
58+
migrations.AddField(
59+
model_name='useragreementrecord',
60+
name='agreement',
61+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='records', to='agreements.useragreement'),
62+
),
63+
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 5.2.10 on 2026-01-26 10:22
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
8+
def migrate_agreement_type(apps, schema_editor):
9+
UserAgreementRecord = apps.get_model('agreements', 'UserAgreementRecord')
10+
UserAgreement = apps.get_model('agreements', 'UserAgreement')
11+
for user_agreement_record in UserAgreementRecord.objects.all():
12+
user_agreement_record.agreement = UserAgreement.objects.get_or_create(type=user_agreement_record.agreement_type, defaults=dict(text=''))
13+
14+
15+
def migrate_agreement_type_rev(apps, schema_editor):
16+
UserAgreementRecord = apps.get_model('agreements', 'UserAgreementRecord')
17+
for user_agreement_record in UserAgreementRecord.objects.all():
18+
user_agreement_record.agreement_type = user_agreement_record.agreement.type
19+
20+
21+
class Migration(migrations.Migration):
22+
23+
dependencies = [
24+
('agreements', '0007_historicaluseragreement_useragreement_and_more'),
25+
]
26+
27+
operations = [
28+
migrations.RunPython(migrate_agreement_type, migrate_agreement_type_rev),
29+
migrations.RemoveField(
30+
model_name='useragreementrecord',
31+
name='agreement_type',
32+
),
33+
migrations.AlterField(
34+
model_name='useragreementrecord',
35+
name='agreement',
36+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='agreements.useragreement'),
37+
),
38+
]

openedx/core/djangoapps/agreements/models.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.db import models
77
from model_utils.models import TimeStampedModel
88
from opaque_keys.edx.django.models import CourseKeyField
9+
from simple_history.models import HistoricalRecords
910

1011
User = get_user_model()
1112

@@ -72,6 +73,43 @@ class Meta:
7273
app_label = 'agreements'
7374

7475

76+
class UserAgreement(models.Model):
77+
"""
78+
This model stores agreements that as user can accept that can gate certain
79+
platform features.
80+
81+
.. no_pii:
82+
"""
83+
type = models.CharField(max_length=255, unique=True)
84+
name = models.CharField(
85+
max_length=255,
86+
help_text='Human-readable name for the agreement type. Will be displayed to users in alert to accept the agreement.',
87+
)
88+
summary = models.TextField(
89+
max_length=1024,
90+
help_text='Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.',
91+
)
92+
text = models.TextField(
93+
help_text='Full text of the agreement. (Required if url is not provided)',
94+
null=True, blank=True,
95+
)
96+
url = models.URLField(
97+
help_text='URL where the full agreement can be accessed. Will be used for "Learn More" link in alert to accept the agreement.',
98+
null=True, blank=True,
99+
)
100+
created = models.DateTimeField(auto_now_add=True)
101+
updated = models.DateTimeField(
102+
help_text='Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again.')
103+
history = HistoricalRecords()
104+
105+
class Meta:
106+
app_label = 'agreements'
107+
constraints = [
108+
models.CheckConstraint(check=models.Q(text__isnull=False) | models.Q(url__isnull=False),
109+
name='agreement_has_text_or_url')
110+
]
111+
112+
75113
class UserAgreementRecord(models.Model):
76114
"""
77115
This model stores the agreements a user has accepted or acknowledged.
@@ -82,7 +120,7 @@ class UserAgreementRecord(models.Model):
82120
.. no_pii:
83121
"""
84122
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
85-
agreement_type = models.CharField(max_length=255)
123+
agreement = models.ForeignKey(UserAgreement, on_delete=models.CASCADE, related_name='records')
86124
timestamp = models.DateTimeField(auto_now_add=True)
87125

88126
class Meta:

openedx/core/djangoapps/agreements/serializers.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,19 @@ class Meta:
3434
fields = ('username', 'course_id', 'lti_tools', 'created_at')
3535

3636

37-
class UserAgreementsSerializer(serializers.Serializer):
37+
class UserAgreementSerializer(serializers.Serializer):
38+
"""
39+
Serializer for UserAgreement model
40+
"""
41+
type = serializers.CharField(read_only=True)
42+
name = serializers.CharField(read_only=True)
43+
summary = serializers.CharField(read_only=True)
44+
text = serializers.CharField(read_only=True)
45+
url = serializers.URLField(read_only=True)
46+
updated = serializers.DateTimeField(read_only=True)
47+
48+
49+
class UserAgreementRecordSerializer(serializers.Serializer):
3850
"""
3951
Serializer for UserAgreementRecord model
4052
"""

openedx/core/djangoapps/agreements/tests/test_api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
get_lti_pii_signature,
2323
get_pii_receiving_lti_tools,
2424
get_latest_user_agreement_record,
25-
get_user_agreements
25+
get_user_agreement_records
2626
)
2727
from ..models import LTIPIITool
2828

@@ -201,11 +201,11 @@ def setUp(self):
201201
self.user = UserFactory()
202202

203203
def test_get_user_agreements(self, ):
204-
result = list(get_user_agreements(self.user))
204+
result = list(get_user_agreement_records(self.user))
205205
assert len(result) == 0
206206

207207
record = create_user_agreement_record(self.user, 'test_type')
208-
result = list(get_user_agreements(self.user))
208+
result = list(get_user_agreement_records(self.user))
209209

210210
assert len(result) == 1
211211
assert result[0].agreement_type == 'test_type'

0 commit comments

Comments
 (0)