Skip to content

Commit de85bd2

Browse files
committed
feat: User agreements API for generic agreement records
This change adds a new kind of generic user agreement that allows plugins or even the core platform to record a user's acknowledgement of an agreement.
1 parent 72959ad commit de85bd2

File tree

9 files changed

+318
-32
lines changed

9 files changed

+318
-32
lines changed

openedx/core/djangoapps/agreements/api.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@
33
"""
44

55
import logging
6+
from datetime import datetime
7+
from typing import Iterable, Optional
68

79
from django.contrib.auth import get_user_model
810
from django.core.exceptions import ObjectDoesNotExist
911
from opaque_keys.edx.keys import CourseKey
1012

11-
from openedx.core.djangoapps.agreements.models import IntegritySignature
12-
from openedx.core.djangoapps.agreements.models import LTIPIITool
13-
from openedx.core.djangoapps.agreements.models import LTIPIISignature
14-
15-
from .data import LTIToolsReceivingPIIData
16-
from .data import LTIPIISignatureData
13+
from .data import LTIPIISignatureData, LTIToolsReceivingPIIData, UserAgreementRecordData
14+
from .models import IntegritySignature, LTIPIISignature, LTIPIITool, UserAgreementRecord
1715

1816
log = logging.getLogger(__name__)
1917
User = get_user_model()
@@ -240,3 +238,48 @@ def _user_signature_out_of_date(username, course_id):
240238
return False
241239
else:
242240
return user_lti_pii_signature_hash != course_lti_pii_tools_hash
241+
242+
243+
def get_user_agreements(user: User) -> Iterable[UserAgreementRecordData]:
244+
"""
245+
Retrieves all the agreements that the specified user has acknowledged.
246+
"""
247+
for agreement_record in UserAgreementRecord.objects.filter(user=user):
248+
yield UserAgreementRecordData.from_model(agreement_record)
249+
250+
251+
def get_latest_user_agreement_record(
252+
user: User,
253+
agreement_type: str,
254+
agreed_after: datetime = None,
255+
) -> Optional[UserAgreementRecordData]:
256+
"""
257+
Retrieve the user agreement record for the specified user and agreement type.
258+
259+
An agreement update timestamp can be provided to return a record only if it
260+
was signed after that timestamp.
261+
"""
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
273+
274+
275+
def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreementRecordData:
276+
"""
277+
Creates a user agreement record if one doesn't already exist, or updates existing
278+
record to current timestamp.
279+
"""
280+
record = UserAgreementRecord.objects.create(
281+
user=user,
282+
agreement_type=agreement_type,
283+
timestamp=datetime.now(),
284+
)
285+
return UserAgreementRecordData.from_model(record)

openedx/core/djangoapps/agreements/data.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
"""
22
Public data structures for this app.
33
"""
4+
from dataclasses import dataclass
5+
from datetime import datetime
6+
47
import attr
58

9+
from .models import UserAgreementRecord
10+
611

712
@attr.s(frozen=True, auto_attribs=True)
813
class LTIToolsReceivingPIIData:
@@ -21,3 +26,21 @@ class LTIPIISignatureData:
2126
course_id: str
2227
lti_tools: str
2328
lti_tools_hash: str
29+
30+
31+
@dataclass
32+
class UserAgreementRecordData:
33+
"""
34+
Data for a single user agreement record.
35+
"""
36+
username: str
37+
agreement_type: str
38+
accepted_at: datetime
39+
40+
@classmethod
41+
def from_model(cls, model: UserAgreementRecord):
42+
return UserAgreementRecordData(
43+
username=model.user.username,
44+
agreement_type=model.agreement_type,
45+
accepted_at=model.timestamp,
46+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 4.2.16 on 2024-12-06 11:34
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('agreements', '0005_timestampedmodels'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='UserAgreementRecord',
18+
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)),
23+
],
24+
),
25+
]

openedx/core/djangoapps/agreements/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,20 @@ class ProctoringPIISignature(TimeStampedModel):
7070

7171
class Meta:
7272
app_label = 'agreements'
73+
74+
75+
class UserAgreementRecord(models.Model):
76+
"""
77+
This model stores the agreements a user has accepted or acknowledged.
78+
79+
Each record here represents a user agreeing to the agreement type represented
80+
by `agreement_type` at a particular time.
81+
82+
.. no_pii:
83+
"""
84+
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
85+
agreement_type = models.CharField(max_length=255)
86+
timestamp = models.DateTimeField(auto_now_add=True)
87+
88+
class Meta:
89+
app_label = 'agreements'

openedx/core/djangoapps/agreements/serializers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
"""
44
from rest_framework import serializers
55

6-
from openedx.core.djangoapps.agreements.models import IntegritySignature, LTIPIISignature
76
from openedx.core.lib.api.serializers import CourseKeyField
87

8+
from .models import IntegritySignature, LTIPIISignature
9+
910

1011
class IntegritySignatureSerializer(serializers.ModelSerializer):
1112
"""
@@ -31,3 +32,12 @@ class LTIPIISignatureSerializer(serializers.ModelSerializer):
3132
class Meta:
3233
model = LTIPIISignature
3334
fields = ('username', 'course_id', 'lti_tools', 'created_at')
35+
36+
37+
class UserAgreementsSerializer(serializers.Serializer):
38+
"""
39+
Serializer for UserAgreementRecord model
40+
"""
41+
username = serializers.CharField(read_only=True)
42+
agreement_type = serializers.CharField(read_only=True)
43+
accepted_at = serializers.DateTimeField()

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

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,29 @@
22
Tests for the Agreements API
33
"""
44
import logging
5+
from datetime import datetime, timedelta
56

7+
from django.test import TestCase
8+
from opaque_keys.edx.keys import CourseKey
69
from testfixtures import LogCapture
710

811
from common.djangoapps.student.tests.factories import UserFactory
9-
from openedx.core.djangoapps.agreements.api import (
12+
from openedx.core.djangolib.testing.utils import skip_unless_lms
13+
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
14+
from xmodule.modulestore.tests.factories import CourseFactory
15+
16+
from ..api import (
1017
create_integrity_signature,
18+
create_lti_pii_signature,
19+
create_user_agreement_record,
1120
get_integrity_signature,
1221
get_integrity_signatures_for_course,
22+
get_lti_pii_signature,
1323
get_pii_receiving_lti_tools,
14-
create_lti_pii_signature,
15-
get_lti_pii_signature
24+
get_latest_user_agreement_record,
25+
get_user_agreements
1626
)
17-
from openedx.core.djangolib.testing.utils import skip_unless_lms
18-
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
19-
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
20-
from ..models import (
21-
LTIPIITool,
22-
)
23-
from opaque_keys.edx.keys import CourseKey
27+
from ..models import LTIPIITool
2428

2529
LOGGER_NAME = "openedx.core.djangoapps.agreements.api"
2630

@@ -186,3 +190,37 @@ def _assert_ltitools(self, lti_list):
186190
Helper function to assert the returned list has the correct tools
187191
"""
188192
self.assertEqual(self.lti_tools, lti_list)
193+
194+
195+
@skip_unless_lms
196+
class UserAgreementsTests(TestCase):
197+
"""
198+
Tests for the python APIs related to user agreements.
199+
"""
200+
def setUp(self):
201+
self.user = UserFactory()
202+
203+
def test_get_user_agreements(self, ):
204+
result = list(get_user_agreements(self.user))
205+
assert len(result) == 0
206+
207+
record = create_user_agreement_record(self.user, 'test_type')
208+
result = list(get_user_agreements(self.user))
209+
210+
assert len(result) == 1
211+
assert result[0].agreement_type == 'test_type'
212+
assert result[0].username == self.user.username
213+
assert result[0].accepted_at == record.accepted_at
214+
215+
def test_get_user_agreement_record(self):
216+
record = create_user_agreement_record(self.user, 'test_type')
217+
result = get_latest_user_agreement_record(self.user, 'test_type')
218+
219+
assert result == record
220+
221+
result = get_latest_user_agreement_record(self.user, 'test_type', datetime.now() + timedelta(days=1))
222+
223+
assert result is None
224+
225+
def tearDown(self):
226+
self.user.delete()

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

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@
22
Tests for agreements views
33
"""
44

5+
import json
56
from datetime import datetime, timedelta
67
from unittest.mock import patch
78

89
from django.conf import settings
910
from django.urls import reverse
10-
from rest_framework.test import APITestCase
11-
from rest_framework import status
1211
from freezegun import freeze_time
13-
import json
12+
from rest_framework import status
13+
from rest_framework.test import APITestCase
1414

15-
from common.djangoapps.student.tests.factories import UserFactory, AdminFactory
1615
from common.djangoapps.student.roles import CourseStaffRole
17-
from openedx.core.djangoapps.agreements.api import (
16+
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
17+
from openedx.core.djangolib.testing.utils import skip_unless_lms
18+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
19+
from xmodule.modulestore.tests.factories import CourseFactory
20+
21+
from ..api import (
1822
create_integrity_signature,
23+
create_user_agreement_record,
1924
get_integrity_signatures_for_course,
2025
get_lti_pii_signature
2126
)
22-
from openedx.core.djangolib.testing.utils import skip_unless_lms
23-
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
24-
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
2527

2628

2729
@skip_unless_lms
@@ -289,3 +291,54 @@ def test_post_lti_pii_signature(self):
289291
signature = get_lti_pii_signature(self.user.username, self.course_id)
290292
self.assertEqual(signature.user.username, self.user.username)
291293
self.assertEqual(signature.lti_tools, self.lti_tools)
294+
295+
296+
@skip_unless_lms
297+
class UserAgreementsViewTests(APITestCase):
298+
"""
299+
Tests for the UserAgreementsView
300+
"""
301+
302+
def setUp(self):
303+
self.user = UserFactory(username="testuser", password="password")
304+
self.url = reverse('user_agreements', kwargs={'agreement_type': 'sample_agreement'})
305+
self.login()
306+
307+
def login(self):
308+
self.client.login(username="testuser", password="password")
309+
310+
def test_get_user_agreement_record_no_data(self):
311+
response = self.client.get(self.url)
312+
assert response.status_code == status.HTTP_404_NOT_FOUND
313+
314+
def test_get_user_agreement_record_invalid_date(self):
315+
response = self.client.get(self.url, {'after': 'invalid_date'})
316+
assert response.status_code == status.HTTP_400_BAD_REQUEST
317+
318+
def test_get_user_agreement_record(self):
319+
create_user_agreement_record(self.user, 'sample_agreement')
320+
response = self.client.get(self.url)
321+
assert response.status_code == status.HTTP_200_OK
322+
assert 'accepted_at' in response.data
323+
324+
response = self.client.get(self.url, {"after": str(datetime.now() + timedelta(days=1))})
325+
assert response.status_code == status.HTTP_404_NOT_FOUND
326+
327+
def test_post_user_agreement(self):
328+
with freeze_time("2024-11-21 12:00:00"):
329+
response = self.client.post(self.url)
330+
assert response.status_code == status.HTTP_201_CREATED
331+
332+
self.login()
333+
334+
response = self.client.get(self.url)
335+
assert response.status_code == status.HTTP_200_OK
336+
337+
response = self.client.get(self.url, {"after": "2024-11-21T13:00:00Z"})
338+
assert response.status_code == status.HTTP_404_NOT_FOUND
339+
340+
response = self.client.post(self.url)
341+
assert response.status_code == status.HTTP_201_CREATED
342+
343+
response = self.client.get(self.url, {"after": "2024-11-21T13:00:00Z"})
344+
assert response.status_code == status.HTTP_200_OK

openedx/core/djangoapps/agreements/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"""
44

55
from django.conf import settings
6-
from django.urls import re_path
6+
from django.urls import path, re_path
77

8-
from .views import IntegritySignatureView, LTIPIISignatureView
8+
from .views import IntegritySignatureView, LTIPIISignatureView, UserAgreementsView
99

1010
urlpatterns = [
1111
re_path(r'^integrity_signature/{course_id}$'.format(
@@ -14,4 +14,5 @@
1414
re_path(r'^lti_pii_signature/{course_id}$'.format(
1515
course_id=settings.COURSE_ID_PATTERN
1616
), LTIPIISignatureView.as_view(), name='lti_pii_signature'),
17+
path("agreement/<slug:agreement_type>", UserAgreementsView.as_view(), name="user_agreements"),
1718
]

0 commit comments

Comments
 (0)