Coverage for baibu/users/consent.py: 100%
45 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Reading and recording consent decisions.
3Feature code asks ``consent_state`` whether a user has agreed to a use of
4their data, and records changes with ``record_consent``. Never update or
5delete ConsentRecord rows directly.
6"""
8from dataclasses import dataclass
9from functools import wraps
11from django.conf import settings
12from django.contrib import messages
13from django.db import transaction
14from django.shortcuts import redirect
15from django.utils import timezone
16from django.utils.translation import gettext_lazy as _
18from .models import ConsentRecord
19from .models import User
21# Higher tiers include the uses allowed by lower ones.
22TIER_RANK = {
23 ConsentRecord.Tier.NONE: 0,
24 ConsentRecord.Tier.EVAL_ONLY: 1,
25 ConsentRecord.Tier.TRAINING_ELIGIBLE: 2,
26}
29@dataclass(frozen=True)
30class ConsentState:
31 latest_record: ConsentRecord | None
33 @property
34 def tier(self) -> str:
35 record = self.latest_record
36 if record is None or record.revoked_at is not None:
37 return ConsentRecord.Tier.NONE
38 return record.tier
40 def allows(self, tier: str) -> bool:
41 return TIER_RANK[self.tier] >= TIER_RANK[tier]
44def default_scope() -> str:
45 return settings.CONSENT_DEFAULT_SCOPE
48def latest_consent(*, user: User, scope: str | None = None) -> ConsentRecord | None:
49 return user.consent_records.filter(scope=scope or default_scope()).order_by("-granted_at", "-created_at").first()
52def consent_state(*, user: User, scope: str | None = None) -> ConsentState:
53 return ConsentState(latest_record=latest_consent(user=user, scope=scope))
56def record_consent(
57 *,
58 user: User,
59 tier: str,
60 source: str,
61 scope: str | None = None,
62 metadata: dict | None = None,
63) -> ConsentRecord:
64 """Record a new consent decision and revoke the one it replaces."""
65 scope = scope or default_scope()
66 now = timezone.now()
67 with transaction.atomic():
68 ConsentRecord.objects.select_for_update().filter(
69 user=user,
70 scope=scope,
71 revoked_at__isnull=True,
72 ).update(revoked_at=now)
73 return ConsentRecord.objects.create(
74 user=user,
75 tier=tier,
76 source=source,
77 scope=scope,
78 granted_at=now,
79 metadata=dict(metadata or {}),
80 )
83def consent_required(tier: str, *, scope: str | None = None, redirect_to: str = "users:consent"):
84 """View decorator: send users without at least ``tier`` consent to the consent page."""
86 def decorator(view):
87 @wraps(view)
88 def wrapper(request, *args, **kwargs):
89 if not request.user.is_authenticated:
90 return redirect(settings.LOGIN_URL)
91 if not consent_state(user=request.user, scope=scope).allows(tier):
92 messages.info(request, _("Please review how your contributions may be used before continuing."))
93 return redirect(redirect_to)
94 return view(request, *args, **kwargs)
96 return wrapper
98 return decorator