Skip to content
Open
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
3 changes: 3 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,9 @@ def make_lms_template_path(settings):

'common.djangoapps.xblock_django',

# Agreements
'openedx.core.djangoapps.agreements',

# Catalog integration
'openedx.core.djangoapps.catalog',

Expand Down
26 changes: 22 additions & 4 deletions openedx/core/djangoapps/agreements/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
63 changes: 57 additions & 6 deletions openedx/core/djangoapps/agreements/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
"""

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()
Expand Down Expand Up @@ -240,3 +248,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)
49 changes: 49 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 datetime import datetime

import attr

from openedx.core.djangoapps.agreements.models import UserAgreement, UserAgreementRecord


@attr.s(frozen=True, auto_attribs=True)
class LTIToolsReceivingPIIData:
Expand All @@ -21,3 +26,47 @@ class LTIPIISignatureData:
course_id: str
lti_tools: str
lti_tools_hash: str


@attr.s(frozen=True, auto_attribs=True)
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),
)


@attr.s(frozen=True, auto_attribs=True)
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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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,141 @@
# 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 an alert to accept/reject 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 an alert to accept/reject 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,40 @@
# 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"
),
),
]
Loading
Loading