Skip to content

Commit 78861a4

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 9274852 commit 78861a4

7 files changed

Lines changed: 142 additions & 9 deletions

File tree

openedx/core/djangoapps/agreements/api.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
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 IntegritySignature, UserAgreementRecord
1314
from openedx.core.djangoapps.agreements.models import LTIPIISignature
14-
15-
from .data import LTIToolsReceivingPIIData
15+
from openedx.core.djangoapps.agreements.models import LTIPIITool
1616
from .data import LTIPIISignatureData
17+
from .data import LTIToolsReceivingPIIData, UserAgreementRecordData
1718

1819
log = logging.getLogger(__name__)
1920
User = get_user_model()
@@ -240,3 +241,38 @@ def _user_signature_out_of_date(username, course_id):
240241
return False
241242
else:
242243
return user_lti_pii_signature_hash != course_lti_pii_tools_hash
244+
245+
246+
def get_user_agreements(user: User) -> Iterable[UserAgreementRecordData]:
247+
for agreement_record in UserAgreementRecord.objects.filter(user=user):
248+
yield UserAgreementRecordData.from_model(agreement_record)
249+
250+
251+
def get_user_agreement_record(
252+
user: User,
253+
agreement_type: str,
254+
agreement_update_timestamp: datetime = None,
255+
) -> Optional[UserAgreementRecordData]:
256+
try:
257+
record_query = UserAgreementRecord.objects.filter(
258+
user=user,
259+
agreement_type=agreement_type,
260+
)
261+
if agreement_update_timestamp:
262+
record_query = record_query.filter(timestamp__gte=agreement_update_timestamp)
263+
record = record_query.get()
264+
return UserAgreementRecordData.from_model(record)
265+
except UserAgreementRecord.DoesNotExist:
266+
return None
267+
268+
269+
def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreementRecordData:
270+
record, _ = UserAgreementRecord.objects.update_or_create(
271+
user=user,
272+
agreement_type=agreement_type,
273+
defaults={
274+
"timestamp": datetime.now(),
275+
},
276+
)
277+
return UserAgreementRecordData.from_model(record)
278+

openedx/core/djangoapps/agreements/data.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"""
22
Public data structures for this app.
33
"""
4+
from datetime import datetime
5+
46
import attr
7+
from dataclasses import dataclass
8+
9+
from openedx.core.djangoapps.agreements.models import UserAgreementRecord
510

611

712
@attr.s(frozen=True, auto_attribs=True)
@@ -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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.16 on 2024-11-14 11:47
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+
options={
25+
'unique_together': {('user', 'agreement_type')},
26+
},
27+
),
28+
]

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+
class UserAgreementRecord(models.Model):
75+
"""
76+
This model stores the agreements a user has accepted or acknowledged.
77+
78+
Each record here represents a user agreeing to the agreement type represented
79+
by `agreement_type`.
80+
81+
.. no_pii:
82+
"""
83+
user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
84+
agreement_type = models.CharField(max_length=255)
85+
timestamp = models.DateTimeField(auto_now_add=True)
86+
87+
class Meta:
88+
app_label = 'agreements'
89+
unique_together = [['user', 'agreement_type']]

openedx/core/djangoapps/agreements/serializers.py

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

6-
from openedx.core.djangoapps.agreements.models import IntegritySignature, LTIPIISignature
6+
from openedx.core.djangoapps.agreements.models import IntegritySignature, LTIPIISignature, UserAgreementRecord
77
from openedx.core.lib.api.serializers import CourseKeyField
88

99

@@ -31,3 +31,6 @@ class LTIPIISignatureSerializer(serializers.ModelSerializer):
3131
class Meta:
3232
model = LTIPIISignature
3333
fields = ('username', 'course_id', 'lti_tools', 'created_at')
34+
35+
class UserAgreementsSerializer(serializers.Serializer):
36+
accepted_at = serializers.DateTimeField()

openedx/core/djangoapps/agreements/urls.py

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

55
from django.conf import settings
6-
from django.urls import re_path
6+
from django.urls import re_path, path
7+
from django.urls.conf import include
78

8-
from .views import IntegritySignatureView, LTIPIISignatureView
9+
from .views import IntegritySignatureView, LTIPIISignatureView, UserAgreementsView
910

1011
urlpatterns = [
1112
re_path(r'^integrity_signature/{course_id}$'.format(
@@ -14,4 +15,5 @@
1415
re_path(r'^lti_pii_signature/{course_id}$'.format(
1516
course_id=settings.COURSE_ID_PATTERN
1617
), LTIPIISignatureView.as_view(), name='lti_pii_signature'),
18+
path("agreement/<slug:agreement_type>", UserAgreementsView.as_view(), name="user_agreements"),
1719
]

openedx/core/djangoapps/agreements/views.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""
22
Views served by the Agreements app
33
"""
4+
from os import times
45

56
from django.conf import settings
7+
from django import forms
68
from rest_framework import status
79
from rest_framework.views import APIView
810
from rest_framework.response import Response
@@ -14,9 +16,10 @@
1416
from openedx.core.djangoapps.agreements.api import (
1517
create_integrity_signature,
1618
create_lti_pii_signature,
17-
get_integrity_signature,
19+
get_integrity_signature, get_user_agreement_record, create_user_agreement_record,
1820
)
19-
from openedx.core.djangoapps.agreements.serializers import IntegritySignatureSerializer, LTIPIISignatureSerializer
21+
from openedx.core.djangoapps.agreements.serializers import IntegritySignatureSerializer, LTIPIISignatureSerializer, \
22+
UserAgreementsSerializer
2023

2124

2225
def is_user_course_or_global_staff(user, course_id):
@@ -159,3 +162,24 @@ def post(self, request, course_id):
159162
else:
160163
statusStr = status.HTTP_500_INTERNAL_SERVER_ERROR
161164
return Response(data=serializer.data, status=statusStr)
165+
166+
167+
class UserAgreementsView(AuthenticatedAPIView):
168+
169+
class QueryFilterForm(forms.Form):
170+
after = forms.DateTimeField(required=False)
171+
172+
def get(self, request, agreement_type):
173+
params = UserAgreementsView.QueryFilterForm(request.query_params)
174+
if not params.is_valid():
175+
return Response(status=status.HTTP_400_BAD_REQUEST)
176+
record = get_user_agreement_record(request.user, agreement_type, params.cleaned_data.get('after'))
177+
if record is None:
178+
return Response(status=status.HTTP_404_NOT_FOUND)
179+
serializer = UserAgreementsSerializer(record)
180+
return Response(serializer.data)
181+
182+
def post(self, request, agreement_type):
183+
record = create_user_agreement_record(request.user, agreement_type)
184+
serializer = UserAgreementsSerializer(record)
185+
return Response(serializer.data, status=status.HTTP_201_CREATED)

0 commit comments

Comments
 (0)