-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat: User agreements API for generic agreement records #35895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
34bc457
1d12ffd
3a1513c
eb0ad71
8b5190e
4ab9eb7
3c02a43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,17 +3,24 @@ | |
| """ | ||
|
|
||
| import logging | ||
| from datetime import datetime | ||
| from typing import Iterable, Optional | ||
|
|
||
| from django.contrib.auth import get_user_model | ||
| from django.core.exceptions import ObjectDoesNotExist | ||
| from opaque_keys.edx.keys import CourseKey | ||
|
|
||
| from openedx.core.djangoapps.agreements.models import IntegritySignature | ||
| from openedx.core.djangoapps.agreements.models import LTIPIITool | ||
| from openedx.core.djangoapps.agreements.models import LTIPIISignature | ||
|
|
||
| from .data import LTIToolsReceivingPIIData | ||
| from .data import LTIPIISignatureData | ||
| from openedx.core.djangoapps.agreements.data import ( | ||
| LTIPIISignatureData, | ||
| LTIToolsReceivingPIIData, | ||
| UserAgreementRecordData | ||
| ) | ||
| from openedx.core.djangoapps.agreements.models import ( | ||
| IntegritySignature, | ||
| LTIPIISignature, | ||
| LTIPIITool, | ||
| UserAgreementRecord | ||
| ) | ||
|
|
||
| log = logging.getLogger(__name__) | ||
| User = get_user_model() | ||
|
|
@@ -240,3 +247,45 @@ def _user_signature_out_of_date(username, course_id): | |
| return False | ||
| else: | ||
| return user_lti_pii_signature_hash != course_lti_pii_tools_hash | ||
|
|
||
|
|
||
| def get_user_agreement_records(user: User) ->Iterator[UserAgreementRecordData]: | ||
| """ | ||
| Retrieves all the agreements that the specified user has acknowledged. | ||
| """ | ||
| for agreement_record in UserAgreementRecord.objects.filter(user=user).select_related("agreement", "user"): | ||
| yield UserAgreementRecordData.from_model(agreement_record) | ||
|
|
||
|
|
||
| def get_latest_user_agreement_record( | ||
| user: User, | ||
| agreement_type: str, | ||
| ) -> Optional[UserAgreementRecordData]: | ||
| """ | ||
| Retrieve the user agreement record for the specified user and agreement type. | ||
|
|
||
| An agreement update timestamp can be provided to return a record only if it | ||
| was signed after that timestamp. | ||
| """ | ||
| try: | ||
| record_query = UserAgreementRecord.objects.filter( | ||
| user=user, | ||
| agreement__type=agreement_type, | ||
| ) | ||
| record = record_query.latest("timestamp") | ||
| return UserAgreementRecordData.from_model(record) | ||
| except UserAgreementRecord.DoesNotExist: | ||
| return None | ||
|
|
||
|
|
||
| def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreementRecordData: | ||
| """ | ||
| Creates a user agreement record if one doesn't already exist, or updates existing | ||
| record to current timestamp. | ||
|
Comment on lines
+283
to
+284
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we are creating or updating, should we use create_or_update instead? ref: https://docs.djangoproject.com/en/6.0/ref/models/querysets/#update-or-create |
||
| """ | ||
| record = UserAgreementRecord.objects.create( | ||
| user=user, | ||
| agreement__type=agreement_type, | ||
| timestamp=datetime.now(), | ||
| ) | ||
| return UserAgreementRecordData.from_model(record) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # Generated by Django 4.2.16 on 2024-12-06 11:34 | ||
|
|
||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
| import django.db.models.deletion | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ('agreements', '0005_timestampedmodels'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name='UserAgreementRecord', | ||
| fields=[ | ||
| ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
| ('agreement_type', models.CharField(max_length=255)), | ||
| ('timestamp', models.DateTimeField(auto_now_add=True)), | ||
| ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||
| ], | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # Generated by Django 5.2.10 on 2026-01-26 10:21 | ||
|
|
||
| import django.db.models.deletion | ||
| import simple_history.models | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('agreements', '0006_useragreementrecord'), | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name='HistoricalUserAgreement', | ||
| fields=[ | ||
| ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), | ||
| ('type', models.CharField(db_index=True, max_length=255)), | ||
| ('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)), | ||
| ('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)), | ||
| ('text', models.TextField(blank=True, help_text='Full text of the agreement. (Required if url is not provided)', null=True)), | ||
| ('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)), | ||
| ('created', models.DateTimeField(blank=True, editable=False)), | ||
| ('updated', models.DateTimeField(help_text='Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again.')), | ||
| ('history_id', models.AutoField(primary_key=True, serialize=False)), | ||
| ('history_date', models.DateTimeField()), | ||
| ('history_change_reason', models.CharField(max_length=100, null=True)), | ||
| ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), | ||
| ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), | ||
| ], | ||
| options={ | ||
| 'verbose_name': 'historical user agreement', | ||
| 'verbose_name_plural': 'historical user agreements', | ||
| 'ordering': ('-history_date', '-history_id'), | ||
| 'get_latest_by': ('history_date', 'history_id'), | ||
| }, | ||
| bases=(simple_history.models.HistoricalChanges, models.Model), | ||
| ), | ||
| migrations.CreateModel( | ||
| name='UserAgreement', | ||
| fields=[ | ||
| ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
| ('type', models.CharField(max_length=255, unique=True)), | ||
| ('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)), | ||
| ('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)), | ||
| ('text', models.TextField(blank=True, help_text='Full text of the agreement. (Required if url is not provided)', null=True)), | ||
| ('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)), | ||
| ('created', models.DateTimeField(auto_now_add=True)), | ||
| ('updated', models.DateTimeField(help_text='Timestamp of the last update to this agreement. If changed users will be prompted to accept the agreement again.')), | ||
| ], | ||
| options={ | ||
| 'constraints': [models.CheckConstraint(condition=models.Q(('text__isnull', False), ('url__isnull', False), _connector='OR'), name='agreement_has_text_or_url')], | ||
| }, | ||
| ), | ||
| migrations.AddField( | ||
| model_name='useragreementrecord', | ||
| name='agreement', | ||
| field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='records', to='agreements.useragreement'), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # Generated by Django 5.2.10 on 2026-01-26 10:22 | ||
|
|
||
| import django.db.models.deletion | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
|
|
||
| def migrate_agreement_type(apps, schema_editor): | ||
| UserAgreementRecord = apps.get_model('agreements', 'UserAgreementRecord') | ||
| UserAgreement = apps.get_model('agreements', 'UserAgreement') | ||
| for user_agreement_record in UserAgreementRecord.objects.all(): | ||
| user_agreement_record.agreement = UserAgreement.objects.get_or_create(type=user_agreement_record.agreement_type, defaults=dict(text='')) | ||
|
|
||
|
|
||
| def migrate_agreement_type_rev(apps, schema_editor): | ||
| UserAgreementRecord = apps.get_model('agreements', 'UserAgreementRecord') | ||
| for user_agreement_record in UserAgreementRecord.objects.all(): | ||
| user_agreement_record.agreement_type = user_agreement_record.agreement.type | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ('agreements', '0007_historicaluseragreement_useragreement_and_more'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RunPython(migrate_agreement_type, migrate_agreement_type_rev), | ||
| migrations.RemoveField( | ||
| model_name='useragreementrecord', | ||
| name='agreement_type', | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='useragreementrecord', | ||
| name='agreement', | ||
| field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='agreements.useragreement'), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,3 +31,24 @@ class LTIPIISignatureSerializer(serializers.ModelSerializer): | |
| class Meta: | ||
| model = LTIPIISignature | ||
| fields = ('username', 'course_id', 'lti_tools', 'created_at') | ||
|
|
||
|
|
||
| class UserAgreementSerializer(serializers.Serializer): | ||
| """ | ||
| Serializer for UserAgreement model | ||
| """ | ||
| type = serializers.CharField(read_only=True) | ||
| name = serializers.CharField(read_only=True) | ||
| summary = serializers.CharField(read_only=True) | ||
| text = serializers.CharField(read_only=True) | ||
| url = serializers.URLField(read_only=True) | ||
| updated = serializers.DateTimeField(read_only=True) | ||
|
|
||
|
|
||
| class UserAgreementRecordSerializer(serializers.Serializer): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @xitij2000 any specific reason for not using ModelSerializer? I think it might help here. |
||
| """ | ||
| Serializer for UserAgreementRecord model | ||
| """ | ||
| username = serializers.CharField(read_only=True) | ||
| agreement_type = serializers.CharField(read_only=True) | ||
| accepted_at = serializers.DateTimeField() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.