Skip to content

Commit 1425c28

Browse files
committed
feat: New User Agreements API
Adds new models and API to store user agreements such as fair use agreements, terms of service, code of conduct etc. that need to be accepted by the user.
1 parent a0b229a commit 1425c28

File tree

15 files changed

+651
-121
lines changed

15 files changed

+651
-121
lines changed

cms/envs/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,9 @@ def make_lms_template_path(settings):
786786

787787
'common.djangoapps.xblock_django',
788788

789+
# Agreements
790+
'openedx.core.djangoapps.agreements',
791+
789792
# Catalog integration
790793
'openedx.core.djangoapps.catalog',
791794

openedx/core/djangoapps/agreements/admin.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
"""
44

55
from django.contrib import admin
6-
from openedx.core.djangoapps.agreements.models import IntegritySignature
7-
from openedx.core.djangoapps.agreements.models import LTIPIITool
8-
from openedx.core.djangoapps.agreements.models import LTIPIISignature
9-
from openedx.core.djangoapps.agreements.models import ProctoringPIISignature
6+
7+
from openedx.core.djangoapps.agreements.models import (
8+
IntegritySignature,
9+
LTIPIISignature,
10+
LTIPIITool,
11+
ProctoringPIISignature,
12+
UserAgreement,
13+
)
1014

1115

1216
class IntegritySignatureAdmin(admin.ModelAdmin):
@@ -62,3 +66,17 @@ class Meta:
6266

6367

6468
admin.site.register(ProctoringPIISignature, ProctoringPIISignatureAdmin)
69+
70+
71+
class UserAgreementAdmin(admin.ModelAdmin):
72+
"""
73+
Admin for the UserAgreement Model
74+
"""
75+
76+
list_display = ("type", "name", "url", "created", "updated")
77+
78+
class Meta:
79+
model = UserAgreement
80+
81+
82+
admin.site.register(UserAgreement, UserAgreementAdmin)

openedx/core/djangoapps/agreements/api.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@
44

55
import logging
66
from datetime import datetime
7-
from typing import Iterable, Optional
7+
from typing import Iterator
88

99
from django.contrib.auth import get_user_model
1010
from django.core.exceptions import ObjectDoesNotExist
1111
from opaque_keys.edx.keys import CourseKey
1212

13-
from .data import LTIPIISignatureData, LTIToolsReceivingPIIData, UserAgreementRecordData
14-
from .models import IntegritySignature, LTIPIISignature, LTIPIITool, UserAgreementRecord
13+
from openedx.core.djangoapps.agreements.data import (
14+
LTIPIISignatureData,
15+
LTIToolsReceivingPIIData,
16+
UserAgreementRecordData,
17+
)
18+
from openedx.core.djangoapps.agreements.models import (
19+
IntegritySignature,
20+
LTIPIISignature,
21+
LTIPIITool,
22+
UserAgreementRecord,
23+
UserAgreement,
24+
)
1525

1626
log = logging.getLogger(__name__)
1727
User = get_user_model()
@@ -240,46 +250,44 @@ def _user_signature_out_of_date(username, course_id):
240250
return user_lti_pii_signature_hash != course_lti_pii_tools_hash
241251

242252

243-
def get_user_agreements(user: User) -> Iterable[UserAgreementRecordData]:
253+
def get_user_agreement_records(user: User) -> Iterator[UserAgreementRecordData]:
244254
"""
245255
Retrieves all the agreements that the specified user has acknowledged.
246256
"""
247-
for agreement_record in UserAgreementRecord.objects.filter(user=user):
257+
for agreement_record in UserAgreementRecord.objects.filter(user=user).select_related("agreement", "user"):
248258
yield UserAgreementRecordData.from_model(agreement_record)
249259

250260

251261
def get_latest_user_agreement_record(
252262
user: User,
253263
agreement_type: str,
254-
agreed_after: datetime = None,
255-
) -> Optional[UserAgreementRecordData]:
264+
) -> UserAgreementRecordData:
256265
"""
257266
Retrieve the user agreement record for the specified user and agreement type.
258267
259268
An agreement update timestamp can be provided to return a record only if it
260269
was signed after that timestamp.
261270
"""
262-
try:
263-
record_query = UserAgreementRecord.objects.filter(
264-
user=user,
265-
agreement_type=agreement_type,
266-
)
267-
if agreed_after:
268-
record_query = record_query.filter(timestamp__gte=agreed_after)
269-
record = record_query.latest("timestamp")
270-
return UserAgreementRecordData.from_model(record)
271-
except UserAgreementRecord.DoesNotExist:
272-
return None
271+
record_query = UserAgreementRecord.objects.filter(
272+
user=user,
273+
agreement__type=agreement_type,
274+
)
275+
if record_query.exists():
276+
return UserAgreementRecordData.from_model(record_query.latest("timestamp"))
277+
return UserAgreementRecordData(
278+
username=user.get_username(),
279+
agreement_type=agreement_type,
280+
)
273281

274282

275283
def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreementRecordData:
276284
"""
277-
Creates a user agreement record if one doesn't already exist, or updates existing
278-
record to current timestamp.
285+
Creates a user agreement record with current timestamp.
279286
"""
287+
agreement = UserAgreement.objects.get(type=agreement_type)
280288
record = UserAgreementRecord.objects.create(
281289
user=user,
282-
agreement_type=agreement_type,
290+
agreement=agreement,
283291
timestamp=datetime.now(),
284292
)
285293
return UserAgreementRecordData.from_model(record)

openedx/core/djangoapps/agreements/data.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""
22
Public data structures for this app.
33
"""
4-
from dataclasses import dataclass
4+
55
from datetime import datetime
66

77
import attr
88

9-
from .models import UserAgreementRecord
9+
from openedx.core.djangoapps.agreements.models import UserAgreement, UserAgreementRecord
1010

1111

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

3030

31-
@dataclass
31+
@attr.s(frozen=True, auto_attribs=True)
32+
class UserAgreementData:
33+
"""
34+
Data for a user agreement record.
35+
"""
36+
37+
type: str
38+
name: str
39+
summary: str
40+
has_text: bool
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+
url=model.url,
50+
has_text=bool(model.text),
51+
)
52+
53+
54+
@attr.s(frozen=True, auto_attribs=True)
3255
class UserAgreementRecordData:
3356
"""
3457
Data for a single user agreement record.
3558
"""
59+
3660
username: str
3761
agreement_type: str
38-
accepted_at: datetime
62+
accepted_at: datetime | None = None
63+
is_current: bool = False
3964

4065
@classmethod
4166
def from_model(cls, model: UserAgreementRecord):
4267
return UserAgreementRecordData(
4368
username=model.user.username,
44-
agreement_type=model.agreement_type,
69+
agreement_type=model.agreement.type,
4570
accepted_at=model.timestamp,
71+
is_current=model.is_current,
4672
)

openedx/core/djangoapps/agreements/migrations/0006_useragreementrecord.py

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

77

88
class Migration(migrations.Migration):
9-
109
dependencies = [
1110
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12-
('agreements', '0005_timestampedmodels'),
11+
("agreements", "0005_timestampedmodels"),
1312
]
1413

1514
operations = [
1615
migrations.CreateModel(
17-
name='UserAgreementRecord',
16+
name="UserAgreementRecord",
1817
fields=[
19-
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20-
('agreement_type', models.CharField(max_length=255)),
21-
('timestamp', models.DateTimeField(auto_now_add=True)),
22-
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
18+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
19+
("agreement_type", models.CharField(max_length=255)),
20+
("timestamp", models.DateTimeField(auto_now_add=True)),
21+
("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
2322
],
2423
),
2524
]
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
dependencies = [
11+
("agreements", "0006_useragreementrecord"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="HistoricalUserAgreement",
18+
fields=[
19+
("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")),
20+
("type", models.CharField(db_index=True, max_length=255)),
21+
(
22+
"name",
23+
models.CharField(
24+
help_text="Human-readable name for the agreement type. Will be displayed to users in an alert to accept/reject the agreement.",
25+
max_length=255,
26+
),
27+
),
28+
(
29+
"summary",
30+
models.TextField(
31+
help_text="Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.",
32+
max_length=1024,
33+
),
34+
),
35+
(
36+
"text",
37+
models.TextField(
38+
blank=True, help_text="Full text of the agreement. (Required if url is not provided)", null=True
39+
),
40+
),
41+
(
42+
"url",
43+
models.URLField(
44+
blank=True,
45+
help_text='URL where the full agreement can be accessed. Will be used for "Learn More" link in alert to accept the agreement.',
46+
null=True,
47+
),
48+
),
49+
("created", models.DateTimeField(blank=True, editable=False)),
50+
(
51+
"updated",
52+
models.DateTimeField(
53+
help_text="Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again."
54+
),
55+
),
56+
("history_id", models.AutoField(primary_key=True, serialize=False)),
57+
("history_date", models.DateTimeField()),
58+
("history_change_reason", models.CharField(max_length=100, null=True)),
59+
(
60+
"history_type",
61+
models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1),
62+
),
63+
(
64+
"history_user",
65+
models.ForeignKey(
66+
null=True,
67+
on_delete=django.db.models.deletion.SET_NULL,
68+
related_name="+",
69+
to=settings.AUTH_USER_MODEL,
70+
),
71+
),
72+
],
73+
options={
74+
"verbose_name": "historical user agreement",
75+
"verbose_name_plural": "historical user agreements",
76+
"ordering": ("-history_date", "-history_id"),
77+
"get_latest_by": ("history_date", "history_id"),
78+
},
79+
bases=(simple_history.models.HistoricalChanges, models.Model),
80+
),
81+
migrations.CreateModel(
82+
name="UserAgreement",
83+
fields=[
84+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
85+
("type", models.CharField(max_length=255, unique=True)),
86+
(
87+
"name",
88+
models.CharField(
89+
help_text="Human-readable name for the agreement type. Will be displayed to users in an alert to accept/reject the agreement.",
90+
max_length=255,
91+
),
92+
),
93+
(
94+
"summary",
95+
models.TextField(
96+
help_text="Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.",
97+
max_length=1024,
98+
),
99+
),
100+
(
101+
"text",
102+
models.TextField(
103+
blank=True, help_text="Full text of the agreement. (Required if url is not provided)", null=True
104+
),
105+
),
106+
(
107+
"url",
108+
models.URLField(
109+
blank=True,
110+
help_text='URL where the full agreement can be accessed. Will be used for "Learn More" link in alert to accept the agreement.',
111+
null=True,
112+
),
113+
),
114+
("created", models.DateTimeField(auto_now_add=True)),
115+
(
116+
"updated",
117+
models.DateTimeField(
118+
help_text="Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again."
119+
),
120+
),
121+
],
122+
options={
123+
"constraints": [
124+
models.CheckConstraint(
125+
condition=models.Q(("text__isnull", False), ("url__isnull", False), _connector="OR"),
126+
name="agreement_has_text_or_url",
127+
)
128+
],
129+
},
130+
),
131+
migrations.AddField(
132+
model_name="useragreementrecord",
133+
name="agreement",
134+
field=models.ForeignKey(
135+
null=True,
136+
on_delete=django.db.models.deletion.CASCADE,
137+
related_name="records",
138+
to="agreements.useragreement",
139+
),
140+
),
141+
]

0 commit comments

Comments
 (0)