Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion openedx/core/djangoapps/agreements/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

from django.contrib import admin
from openedx.core.djangoapps.agreements.models import IntegritySignature
from openedx.core.djangoapps.agreements.models import IntegritySignature, UserAgreement
from openedx.core.djangoapps.agreements.models import LTIPIITool
from openedx.core.djangoapps.agreements.models import LTIPIISignature
from openedx.core.djangoapps.agreements.models import ProctoringPIISignature
Expand Down Expand Up @@ -62,3 +62,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)
52 changes: 46 additions & 6 deletions openedx/core/djangoapps/agreements/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@
"""

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 .data import LTIPIISignatureData, LTIToolsReceivingPIIData, UserAgreementRecordData
from .models import IntegritySignature, LTIPIISignature, LTIPIITool, UserAgreementRecord
Comment on lines -15 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid relative import and go with absolute imports


log = logging.getLogger(__name__)
User = get_user_model()
Expand Down Expand Up @@ -240,3 +238,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) -> Iterable[UserAgreementRecordData]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_user_agreement_records(user: User) -> Iterable[UserAgreementRecordData]:
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"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for agreement_record in UserAgreementRecord.objects.filter(user=user).select_related("agreement"):
for agreement_record in UserAgreementRecord.objects.filter(user=user).select_related("agreement", "user"):

Since we access the username in the dataclass, if we don't use this, there will be multiple queries to fetch users.

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
Comment on lines +261 to +269
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
user_agreement_records = None
record_query = UserAgreementRecord.objects.filter(
user=user,
agreement__type=agreement_type,
)
record = record_query.latest("timestamp")
if record.exists():
user_agreement_records = UserAgreementRecordData.from_model(record)
return user_agreement_records



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 +274 to +275
Copy link
Member

Choose a reason for hiding this comment

The 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)
47 changes: 47 additions & 0 deletions openedx/core/djangoapps/agreements/data.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""
Public data structures for this app.
"""
from dataclasses import dataclass
from datetime import datetime

import attr

from .models import UserAgreementRecord, UserAgreement
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative import!



@attr.s(frozen=True, auto_attribs=True)
class LTIToolsReceivingPIIData:
Expand All @@ -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
text: str|None
url: str|None

@classmethod
def from_model(cls, model: UserAgreement):
return UserAgreementData(
type=model.type,
name=model.name,
summary=model.summary,
text=model.text,
url=model.url
)

@dataclass
class UserAgreementRecordData:
"""
Data for a single user agreement record.
"""
username: str
agreement_type: str
accepted_at: datetime
is_current: bool = True

@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
)
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'),
),
]
55 changes: 55 additions & 0 deletions openedx/core/djangoapps/agreements/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -70,3 +71,57 @@ class ProctoringPIISignature(TimeStampedModel):

class Meta:
app_label = 'agreements'


class UserAgreement(models.Model):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use "TimeStampedModel" here, which will give us created and updated dates

"""
This model stores agreements that as user can accept that can gate certain
platform features.
Comment on lines +78 to +79
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This model stores agreements that as user can accept that can gate certain
platform features.
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 alert to accept the agreement.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
help_text='Human-readable name for the agreement type. Will be displayed to users in alert to accept the agreement.',
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.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
help_text='Brief summary of the agreement content. Will be displayed to users in alert to accept the agreement.',
help_text='Brief summary of the agreement content. Will be displayed to users in an alert to accept the agreement.',

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a required field?

)
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'
24 changes: 23 additions & 1 deletion openedx/core/djangoapps/agreements/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"""
from rest_framework import serializers

from openedx.core.djangoapps.agreements.models import IntegritySignature, LTIPIISignature
from openedx.core.lib.api.serializers import CourseKeyField

from .models import IntegritySignature, LTIPIISignature
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relative imports?



class IntegritySignatureSerializer(serializers.ModelSerializer):
"""
Expand All @@ -31,3 +32,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):
Copy link
Member

Choose a reason for hiding this comment

The 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()
Loading
Loading