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

1"""Reading and recording consent decisions. 

2 

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""" 

7 

8from dataclasses import dataclass 

9from functools import wraps 

10 

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 _ 

17 

18from .models import ConsentRecord 

19from .models import User 

20 

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} 

27 

28 

29@dataclass(frozen=True) 

30class ConsentState: 

31 latest_record: ConsentRecord | None 

32 

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 

39 

40 def allows(self, tier: str) -> bool: 

41 return TIER_RANK[self.tier] >= TIER_RANK[tier] 

42 

43 

44def default_scope() -> str: 

45 return settings.CONSENT_DEFAULT_SCOPE 

46 

47 

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

50 

51 

52def consent_state(*, user: User, scope: str | None = None) -> ConsentState: 

53 return ConsentState(latest_record=latest_consent(user=user, scope=scope)) 

54 

55 

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 ) 

81 

82 

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.""" 

85 

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) 

95 

96 return wrapper 

97 

98 return decorator