diff --git a/openedx/core/djangoapps/agreements/admin.py b/openedx/core/djangoapps/agreements/admin.py index 82e2427dff50..5acdebbbaf15 100644 --- a/openedx/core/djangoapps/agreements/admin.py +++ b/openedx/core/djangoapps/agreements/admin.py @@ -3,10 +3,14 @@ """ from django.contrib import admin -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 openedx.core.djangoapps.agreements.models import ProctoringPIISignature + +from openedx.core.djangoapps.agreements.models import ( + IntegritySignature, + LTIPIISignature, + LTIPIITool, + ProctoringPIISignature, + UserAgreement +) class IntegritySignatureAdmin(admin.ModelAdmin): @@ -62,3 +66,17 @@ class Meta: admin.site.register(ProctoringPIISignature, ProctoringPIISignatureAdmin) + + +class UserAgreementAdmin(admin.ModelAdmin): + """ + Admin for the UserAgreement Model + """ + + list_display = ('type', 'name', 'url', 'created', 'updated') + + class Meta: + model = UserAgreement + + +admin.site.register(UserAgreement, UserAgreementAdmin) diff --git a/openedx/core/djangoapps/agreements/api.py b/openedx/core/djangoapps/agreements/api.py index 2489cefb8533..9f82d183508a 100644 --- a/openedx/core/djangoapps/agreements/api.py +++ b/openedx/core/djangoapps/agreements/api.py @@ -3,17 +3,24 @@ """ import logging +from datetime import datetime +from typing import Optional, Iterator 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, UserAgreement +) log = logging.getLogger(__name__) User = get_user_model() @@ -240,3 +247,46 @@ 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. + """ + record_query = UserAgreementRecord.objects.filter( + user=user, + agreement__type=agreement_type, + ) + if record_query.exists(): + return UserAgreementRecordData.from_model(record_query.latest("timestamp")) + return UserAgreementRecordData( + username=user.get_username(), + agreement_type=agreement_type, + ) + + +def create_user_agreement_record(user: User, agreement_type: str) -> UserAgreementRecordData: + """ + Creates a user agreement record with current timestamp. + """ + agreement = UserAgreement.objects.get(type=agreement_type) + record = UserAgreementRecord.objects.create( + user=user, + agreement=agreement, + timestamp=datetime.now(), + ) + return UserAgreementRecordData.from_model(record) diff --git a/openedx/core/djangoapps/agreements/data.py b/openedx/core/djangoapps/agreements/data.py index 9d843c73cb04..ed4df80204bb 100644 --- a/openedx/core/djangoapps/agreements/data.py +++ b/openedx/core/djangoapps/agreements/data.py @@ -1,8 +1,13 @@ """ Public data structures for this app. """ +from dataclasses import dataclass +from datetime import datetime + import attr +from openedx.core.djangoapps.agreements.models import UserAgreement, UserAgreementRecord + @attr.s(frozen=True, auto_attribs=True) class LTIToolsReceivingPIIData: @@ -21,3 +26,45 @@ class LTIPIISignatureData: course_id: str lti_tools: str lti_tools_hash: str + + +@dataclass +class UserAgreementData: + """ + Data for a user agreement record. + """ + type: str + name: str + summary: str + has_text: bool + url: str | None + + @classmethod + def from_model(cls, model: UserAgreement): + return UserAgreementData( + type=model.type, + name=model.name, + summary=model.summary, + url=model.url, + has_text=bool(model.text), + ) + + +@dataclass +class UserAgreementRecordData: + """ + Data for a single user agreement record. + """ + username: str + agreement_type: str + accepted_at: datetime | None = None + is_current: bool = False + + @classmethod + def from_model(cls, model: UserAgreementRecord): + return UserAgreementRecordData( + username=model.user.username, + agreement_type=model.agreement.type, + accepted_at=model.timestamp, + is_current=model.agreement.updated < model.timestamp + ) diff --git a/openedx/core/djangoapps/agreements/migrations/0006_useragreementrecord.py b/openedx/core/djangoapps/agreements/migrations/0006_useragreementrecord.py new file mode 100644 index 000000000000..2e0985adb6de --- /dev/null +++ b/openedx/core/djangoapps/agreements/migrations/0006_useragreementrecord.py @@ -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)), + ], + ), + ] diff --git a/openedx/core/djangoapps/agreements/migrations/0007_historicaluseragreement_useragreement_and_more.py b/openedx/core/djangoapps/agreements/migrations/0007_historicaluseragreement_useragreement_and_more.py new file mode 100644 index 000000000000..13b4dce9c63d --- /dev/null +++ b/openedx/core/djangoapps/agreements/migrations/0007_historicaluseragreement_useragreement_and_more.py @@ -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'), + ), + ] diff --git a/openedx/core/djangoapps/agreements/migrations/0008_remove_useragreementrecord_agreement_type_and_more.py b/openedx/core/djangoapps/agreements/migrations/0008_remove_useragreementrecord_agreement_type_and_more.py new file mode 100644 index 000000000000..c6894ce7aac1 --- /dev/null +++ b/openedx/core/djangoapps/agreements/migrations/0008_remove_useragreementrecord_agreement_type_and_more.py @@ -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'), + ), + ] diff --git a/openedx/core/djangoapps/agreements/models.py b/openedx/core/djangoapps/agreements/models.py index 2672a4f47b24..95da5bd100b5 100644 --- a/openedx/core/djangoapps/agreements/models.py +++ b/openedx/core/djangoapps/agreements/models.py @@ -6,6 +6,7 @@ from django.db import models from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField +from simple_history.models import HistoricalRecords User = get_user_model() @@ -70,3 +71,57 @@ class ProctoringPIISignature(TimeStampedModel): class Meta: app_label = 'agreements' + + +class UserAgreement(models.Model): + """ + This model stores agreements that the user can accept, which can gate certain + platform features. + + .. no_pii: + """ + type = models.CharField(max_length=255, unique=True) + name = models.CharField( + max_length=255, + help_text='Human-readable name for the agreement type. Will be displayed to users in an alert to accept/reject the agreement.', + ) + summary = models.TextField( + max_length=1024, + help_text='Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.', + ) + text = models.TextField( + help_text='Full text of the agreement. (Required if url is not provided)', + null=True, blank=True, + ) + url = models.URLField( + 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, blank=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.') + history = HistoricalRecords() + + class Meta: + app_label = 'agreements' + constraints = [ + models.CheckConstraint(check=models.Q(text__isnull=False) | models.Q(url__isnull=False), + name='agreement_has_text_or_url') + ] + + +class UserAgreementRecord(models.Model): + """ + This model stores the agreements a user has accepted or acknowledged. + + Each record here represents a user agreeing to the agreement type represented + by `agreement_type` at a particular time. + + .. no_pii: + """ + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + agreement = models.ForeignKey(UserAgreement, on_delete=models.CASCADE, related_name='records') + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + app_label = 'agreements' diff --git a/openedx/core/djangoapps/agreements/serializers.py b/openedx/core/djangoapps/agreements/serializers.py index 397a9ba61d54..f591c1e8462d 100644 --- a/openedx/core/djangoapps/agreements/serializers.py +++ b/openedx/core/djangoapps/agreements/serializers.py @@ -31,3 +31,25 @@ 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) + has_text = serializers.BooleanField(read_only=True) + url = serializers.URLField(read_only=True) + updated = serializers.DateTimeField(read_only=True) + + +class UserAgreementRecordSerializer(serializers.Serializer): + """ + Serializer for UserAgreementRecord model + """ + username = serializers.CharField(read_only=True) + agreement_type = serializers.CharField(read_only=True) + accepted_at = serializers.DateTimeField() + is_current = serializers.BooleanField(read_only=True) diff --git a/openedx/core/djangoapps/agreements/tests/test_api.py b/openedx/core/djangoapps/agreements/tests/test_api.py index c66065789939..105d2c5bf683 100644 --- a/openedx/core/djangoapps/agreements/tests/test_api.py +++ b/openedx/core/djangoapps/agreements/tests/test_api.py @@ -2,25 +2,28 @@ Tests for the Agreements API """ import logging +from datetime import datetime, timedelta +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey from testfixtures import LogCapture from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.agreements.api import ( create_integrity_signature, + create_lti_pii_signature, + create_user_agreement_record, get_integrity_signature, get_integrity_signatures_for_course, + get_latest_user_agreement_record, + get_lti_pii_signature, get_pii_receiving_lti_tools, - create_lti_pii_signature, - get_lti_pii_signature + get_user_agreement_records ) +from openedx.core.djangoapps.agreements.models import LTIPIITool, UserAgreement from openedx.core.djangolib.testing.utils import skip_unless_lms -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -from ..models import ( - LTIPIITool, -) -from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory LOGGER_NAME = "openedx.core.djangoapps.agreements.api" @@ -186,3 +189,49 @@ def _assert_ltitools(self, lti_list): Helper function to assert the returned list has the correct tools """ self.assertEqual(self.lti_tools, lti_list) + + +@skip_unless_lms +class UserAgreementsTests(TestCase): + """ + Tests for the python APIs related to user agreements. + """ + def setUp(self): + self.user = UserFactory() + self.agreement = UserAgreement.objects.create( + type='test_type', + name='test agreement', + summary='test summary', + url='https://example.com', + text='test text', + updated=datetime.now(), + ) + + def test_get_user_agreements(self, ): + result = list(get_user_agreement_records(self.user)) + assert len(result) == 0 + + record = create_user_agreement_record(self.user, 'test_type') + result = list(get_user_agreement_records(self.user)) + + assert len(result) == 1 + assert result[0].agreement_type == 'test_type' + assert result[0].username == self.user.username + assert result[0].accepted_at == record.accepted_at + + def test_get_user_agreement_record(self): + record = create_user_agreement_record(self.user, 'test_type') + result = get_latest_user_agreement_record(self.user, 'test_type') + + assert result == record + + self.agreement.updated = datetime.now() + timedelta(days=1) + self.agreement.save() + + result = get_latest_user_agreement_record(self.user, 'test_type') + + assert result.is_current is False + + def tearDown(self): + self.user.delete() + self.agreement.delete() diff --git a/openedx/core/djangoapps/agreements/tests/test_models.py b/openedx/core/djangoapps/agreements/tests/test_models.py new file mode 100644 index 000000000000..28b45d7f9268 --- /dev/null +++ b/openedx/core/djangoapps/agreements/tests/test_models.py @@ -0,0 +1,90 @@ +""" +Tests for Agreements models +""" +from datetime import datetime + +from django.db import IntegrityError +from django.test import TestCase + +from openedx.core.djangoapps.agreements.models import UserAgreement + + +class UserAgreementModelTest(TestCase): + """ + Tests for the UserAgreement model. + """ + def test_agreement_must_have_text_or_url(self): + """ + Verify that a UserAgreement must have at least a url or text. + """ + # Case 1: Both text and url are provided (Success) + agreement = UserAgreement.objects.create( + type='type1', + name='Name 1', + summary='Summary 1', + text='Some text', + url='https://example.com', + updated=datetime.now(), + ) + self.assertIsNotNone(agreement.pk) + + # Case 2: Only text is provided (Success) + agreement = UserAgreement.objects.create( + type='type2', + name='Name 2', + summary='Summary 2', + text='Some text', + url=None, + updated=datetime.now(), + ) + self.assertIsNotNone(agreement.pk) + + # Case 3: Only url is provided (Success) + agreement = UserAgreement.objects.create( + type='type3', + name='Name 3', + summary='Summary 3', + text=None, + url='https://example.com', + updated=datetime.now(), + ) + self.assertIsNotNone(agreement.pk) + + # Case 4: Neither text nor url is provided (Failure) + with self.assertRaises(IntegrityError): + UserAgreement.objects.create( + type='type4', + name='Name 4', + summary='Summary 4', + text=None, + url=None, + updated=datetime.now(), + ) + + def test_agreement_with_empty_strings(self): + """ + Verify behavior with empty strings. + Since the constraint is `isnull=False`, empty strings should pass + if the DB allows them as NOT NULL. + """ + # Case 5: text is empty string, url is None (Success - because text is NOT NULL) + agreement = UserAgreement.objects.create( + type='type5', + name='Name 5', + summary='Summary 5', + text='', + url=None, + updated=datetime.now(), + ) + self.assertIsNotNone(agreement.pk) + + # Case 6: text is None, url is empty string (Success - because url is NOT NULL) + agreement = UserAgreement.objects.create( + type='type6', + name='Name 6', + summary='Summary 6', + text=None, + url='', + updated=datetime.now(), + ) + self.assertIsNotNone(agreement.pk) diff --git a/openedx/core/djangoapps/agreements/tests/test_views.py b/openedx/core/djangoapps/agreements/tests/test_views.py index 4c52e5853f05..eb4107970261 100644 --- a/openedx/core/djangoapps/agreements/tests/test_views.py +++ b/openedx/core/djangoapps/agreements/tests/test_views.py @@ -2,26 +2,28 @@ Tests for agreements views """ +import json from datetime import datetime, timedelta from unittest.mock import patch from django.conf import settings from django.urls import reverse -from rest_framework.test import APITestCase -from rest_framework import status from freezegun import freeze_time -import json +from rest_framework import status +from rest_framework.test import APITestCase -from common.djangoapps.student.tests.factories import UserFactory, AdminFactory from common.djangoapps.student.roles import CourseStaffRole +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory from openedx.core.djangoapps.agreements.api import ( create_integrity_signature, + create_user_agreement_record, get_integrity_signatures_for_course, get_lti_pii_signature ) +from openedx.core.djangoapps.agreements.models import UserAgreement from openedx.core.djangolib.testing.utils import skip_unless_lms -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory @skip_unless_lms @@ -289,3 +291,68 @@ def test_post_lti_pii_signature(self): signature = get_lti_pii_signature(self.user.username, self.course_id) self.assertEqual(signature.user.username, self.user.username) self.assertEqual(signature.lti_tools, self.lti_tools) + + +@skip_unless_lms +class UserAgreementsViewTests(APITestCase): + """ + Tests for the UserAgreementsView + """ + + def setUp(self): + self.user = UserFactory(username="testuser", password="password") + self.agreement = UserAgreement.objects.create( + type='sample_agreement', + name='sample agreement', + summary='sample summary', + text='sample text', + updated="2024-11-21 11:00:00", + ) + self.url = reverse('user_agreement_record', kwargs={'agreement_type': 'sample_agreement'}) + self.login() + + def login(self): + self.client.login(username="testuser", password="password") + + def test_get_user_agreement_record_for_missing_agreement(self): + response = self.client.get(reverse('user_agreement_record', kwargs={'agreement_type': 'missing_agreement'})) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_current'] is False + assert response.data['accepted_at'] is None + + def test_get_user_agreement_record_missing_record(self): + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_current'] is False + assert response.data['accepted_at'] is None + + def test_get_user_agreement_record(self): + create_user_agreement_record(self.user, 'sample_agreement') + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert 'accepted_at' in response.data + + def test_post_user_agreement(self): + with freeze_time("2024-11-21 12:00:00"): + response = self.client.post(self.url) + assert response.status_code == status.HTTP_201_CREATED + + self.login() + + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_current'] is True + + self.agreement.updated = datetime.now() + self.agreement.save() + + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_current'] is False + + response = self.client.post(self.url) + assert response.status_code == status.HTTP_201_CREATED + + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_current'] is True diff --git a/openedx/core/djangoapps/agreements/toggles.py b/openedx/core/djangoapps/agreements/toggles.py index 6d71abdf6582..4e69b5fe4530 100644 --- a/openedx/core/djangoapps/agreements/toggles.py +++ b/openedx/core/djangoapps/agreements/toggles.py @@ -3,6 +3,7 @@ """ from opaque_keys.edx.keys import CourseKey + from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # .. toggle_name: agreements.enable_lti_pii_acknowledgement diff --git a/openedx/core/djangoapps/agreements/urls.py b/openedx/core/djangoapps/agreements/urls.py index d9d009d65ac1..839a9533ea44 100644 --- a/openedx/core/djangoapps/agreements/urls.py +++ b/openedx/core/djangoapps/agreements/urls.py @@ -3,10 +3,18 @@ """ from django.conf import settings -from django.urls import re_path +from django.urls import path, re_path +from rest_framework.routers import DefaultRouter -from .views import IntegritySignatureView, LTIPIISignatureView +from openedx.core.djangoapps.agreements.views import ( + IntegritySignatureView, + LTIPIISignatureView, + UserAgreementRecordsView, + UserAgreementsViewSet +) +router = DefaultRouter() +router.register(r'agreement', UserAgreementsViewSet, basename='user_agreements') urlpatterns = [ re_path(r'^integrity_signature/{course_id}$'.format( course_id=settings.COURSE_ID_PATTERN @@ -14,4 +22,5 @@ re_path(r'^lti_pii_signature/{course_id}$'.format( course_id=settings.COURSE_ID_PATTERN ), LTIPIISignatureView.as_view(), name='lti_pii_signature'), -] + path("agreement_record/", UserAgreementRecordsView.as_view(), name="user_agreement_record"), +] + router.urls diff --git a/openedx/core/djangoapps/agreements/views.py b/openedx/core/djangoapps/agreements/views.py index cc928669ffdd..b2f67b4e5a87 100644 --- a/openedx/core/djangoapps/agreements/views.py +++ b/openedx/core/djangoapps/agreements/views.py @@ -2,21 +2,34 @@ Views served by the Agreements app """ +import edx_api_doc_tools as apidocs +from django import forms from django.conf import settings -from rest_framework import status -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from drf_yasg import openapi from opaque_keys.edx.keys import CourseKey +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView from common.djangoapps.student import auth from common.djangoapps.student.roles import CourseStaffRole from openedx.core.djangoapps.agreements.api import ( create_integrity_signature, create_lti_pii_signature, + create_user_agreement_record, get_integrity_signature, + get_latest_user_agreement_record ) -from openedx.core.djangoapps.agreements.serializers import IntegritySignatureSerializer, LTIPIISignatureSerializer +from openedx.core.djangoapps.agreements.models import UserAgreement +from openedx.core.djangoapps.agreements.serializers import ( + IntegritySignatureSerializer, + LTIPIISignatureSerializer, + UserAgreementRecordSerializer, + UserAgreementSerializer +) +from openedx.core.lib.api.view_utils import view_auth_classes def is_user_course_or_global_staff(user, course_id): @@ -159,3 +172,134 @@ def post(self, request, course_id): else: statusStr = status.HTTP_500_INTERNAL_SERVER_ERROR return Response(data=serializer.data, status=statusStr) + + +@view_auth_classes(is_authenticated=True) +class UserAgreementRecordsView(APIView): + """ + Endpoint for the user agreement records API. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'agreement_type', + apidocs.ParameterLocation.PATH, + description="Agreement ID/Type", + ), + ], + responses={ + 200: UserAgreementRecordSerializer, + 400: "Bad Request", + 404: "Not Found", + }, + ) + def get(self, request, agreement_type): + """ + Get a user's acknowledgement record for this agreement type. + """ + record = get_latest_user_agreement_record(request.user, agreement_type) + serializer = UserAgreementRecordSerializer(record) + return Response(serializer.data) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'agreement_type', + apidocs.ParameterLocation.PATH, + description="Agreement ID/Type", + ), + ], + responses={ + 200: UserAgreementRecordSerializer, + 400: "Bad Request", + }, + ) + def post(self, request, agreement_type): + """ + Marks a user's acknowledgement of this agreement type. + """ + record = create_user_agreement_record(request.user, agreement_type) + serializer = UserAgreementRecordSerializer(record) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +@view_auth_classes(is_authenticated=True) +class UserAgreementsViewSet(viewsets.GenericViewSet): + """ + Endpoint for the user agreements API. + """ + + queryset = UserAgreement.objects.all() + lookup_field = 'type' + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'agreement_type', + apidocs.ParameterLocation.PATH, + description="Agreement ID/Type", + ), + ], + responses={ + 200: UserAgreementSerializer, + 400: "Bad Request", + 404: "Not Found", + }, + ) + def retrieve(self, request, pk=None): + """ + Get the user agreement for this agreement type. + """ + agreement = UserAgreement.objects.filter(type=pk).get() + serializer = UserAgreementSerializer(agreement) + return Response(serializer.data, status=status.HTTP_200_OK) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + 'agreement_type', + apidocs.ParameterLocation.PATH, + description="Agreement ID/Type", + ), + ], + responses={ + 200: UserAgreementSerializer, + 400: "Bad Request", + 404: "Not Found", + }, + ) + @action(methods=['get'], detail=True) + def text(self, request, pk=None): + """ + Get the text of a user agreement by its type. + """ + agreement = UserAgreement.objects.filter(type=pk).get() + return Response(agreement.text, status=status.HTTP_200_OK) + + @apidocs.schema( + parameters=[ + openapi.Parameter( + 'agreement_type', + apidocs.ParameterLocation.QUERY, + required=False, + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_STRING), + description="Agreement ID/Type", + ), + ], + responses={ + 200: UserAgreementSerializer, + 400: "Bad Request", + }, + ) + def list(self, request): + """ + Get all user agreements for this agreement type. + """ + types = request.query_params.getlist('agreement_type', None) + agreements = UserAgreement.objects.all() + if types: + agreements = agreements.filter(type__in=types) + serializer = UserAgreementSerializer(agreements, many=True) + return Response(serializer.data, status=status.HTTP_200_OK)