Repository navigation
Expand file tree
/
Copy pathmodels.py
More file actions
246 lines (201 loc) · 8.8 KB
/
Copy pathmodels.py
File metadata and controls
246 lines (201 loc) · 8.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)
"""User accounts, access groups (which also grant roles) and strikes."""
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
class User(AbstractUser):
"""Application user.
Roles are intentionally non-exclusive (ADR-0002, #80); always ask
``accounts.roles`` instead of checking fields directly:
- *Admin* — ``is_staff`` / ``is_superuser``, or manual member of an
``AccessGroup`` with ``is_admin`` (e.g. the system group "Administratoren").
- *Lender* — effective member of an ``AccessGroup`` with ``lender_pools``
(every pool has a system group "Verleiher ‹Pool›").
- *Borrower* — any authenticated user (no extra record needed).
"""
# OIDC subject identifier (stable, unique per identity provider).
subject = models.CharField(max_length=255, unique=True, null=True, blank=True)
# True if the account was created via self-registration (deferred feature).
is_self_registered = models.BooleanField(default=False)
# Set when a lender confirms the account at first contact (ADR/concept §6.5).
verified_at = models.DateTimeField(null=True, blank=True)
# Snapshot of the OIDC claims from the last login, used to match
# claim-based access groups (concept §3.4). Refreshed on every login.
claims = models.JSONField(default=dict, blank=True)
# Preferred UI/email language ("en"/"de"), set from the SPA; blank = use the
# site default. Notification emails are sent in this language.
language = models.CharField(max_length=10, blank=True)
# Strike-driven suspension (concept §7.3). A block lasts until ``blocked_until``
# or, when ``blocked_permanently`` is set, indefinitely.
blocked_until = models.DateTimeField(null=True, blank=True)
blocked_permanently = models.BooleanField(default=False)
# Set when the account was anonymized by the data-retention job: all personal
# data is scrubbed and the record kept only as a placeholder ("Gelöschter
# Nutzer") so device/booking history stays intact (see accounts.retention).
anonymized_at = models.DateTimeField(null=True, blank=True)
@property
def is_anonymized(self):
return self.anonymized_at is not None
def is_blocked(self):
"""Whether the user is currently barred from borrowing."""
if self.blocked_permanently:
return True
return bool(self.blocked_until and self.blocked_until > timezone.now())
def __str__(self):
return self.get_username()
class AccessGroup(models.Model):
"""A group used to gate which pools an account may see and book.
Membership is the union of:
- **manual members** (assigned by an admin), and
- **claim matching**: a user whose OIDC claim ``claim_key`` contains any of
``claim_values`` is a member automatically (concept §3.4).
A pool with no access groups is open to all authenticated users; a pool
that lists groups is restricted to members of those groups (plus the pool's
lenders and admins).
"""
name = models.CharField(max_length=255, unique=True)
description = models.TextField(blank=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="access_groups", blank=True
)
# Optional OIDC claim-based membership. Empty claim_key = manual only.
claim_key = models.CharField(
max_length=255,
blank=True,
help_text="OIDC claim to match (e.g. 'groups' or 'department').",
)
claim_values = ArrayField(
models.CharField(max_length=255),
default=list,
blank=True,
help_text="A user whose claim contains any of these values is a member.",
)
pools = models.ManyToManyField(
"catalog.ResourcePool", related_name="access_groups", blank=True
)
# Rights (#80, see accounts.roles). Effective members (manual ∪ claim-matched)
# are lenders of ``lender_pools``.
lender_pools = models.ManyToManyField(
"catalog.ResourcePool", related_name="lender_groups", blank=True
)
# Manual members (never claim matches) are app admins. A group with this flag
# must not carry a claim rule.
is_admin = models.BooleanField(default=False)
# System groups: one lender group per pool ("Verleiher ‹Pool›", follows the
# pool's name and dies with it) and one "Administratoren" group. Both are
# undeletable and have their name, rules and rights locked.
system_pool = models.OneToOneField(
"catalog.ResourcePool",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="lender_group",
)
system_admin = models.BooleanField(default=False)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["system_admin"],
condition=models.Q(system_admin=True),
name="unique_system_admin_group",
),
models.CheckConstraint(
condition=models.Q(is_admin=False) | models.Q(claim_key=""),
name="admin_group_without_claim_rule",
),
]
@property
def is_system(self):
return self.system_pool_id is not None or self.system_admin
def clean(self):
super().clean()
if self.is_admin and self.claim_key:
raise ValidationError(
{"is_admin": "Admin rights can't come from a login rule — "
"remove the claim rule first."}
)
def __str__(self):
return self.name
def default_strike_thresholds():
"""Default escalation: 3 strikes → 30d, 5 → 180d, 7 → permanent."""
return [
{"count": 3, "block_days": 30},
{"count": 5, "block_days": 180},
{"count": 7, "block_days": 0}, # 0 = permanent
]
class StrikeSetting(models.Model):
"""System-wide strike policy (concept §7.3). Singleton (pk forced to 1)."""
# How long a single strike counts before it expires.
strike_expiry_days = models.PositiveIntegerField(default=365)
# Escalation steps: each {count, block_days}; block_days 0 = permanent.
thresholds = models.JSONField(default=default_strike_thresholds)
class Meta:
verbose_name = "strike setting"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return "Strike policy"
class RetentionSetting(models.Model):
"""Data-retention policy for inactive accounts (GDPR). Singleton (pk=1).
When ``enabled``, accounts with no activity for ``retention_days`` and no
open lending process are anonymized by the ``anonymize_inactive_users``
job. Off by default so it is switched on deliberately (concept §7 / privacy).
"""
enabled = models.BooleanField(default=False)
# Inactivity window before an account is anonymized (default 3 years).
retention_days = models.PositiveIntegerField(default=1095)
class Meta:
verbose_name = "retention setting"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return "Data-retention policy"
class Strike(models.Model):
"""A warning issued to a user, with a mandatory reason (concept §7.3).
Strikes are pool-wide (they attach to the user, not a pool) and expire after
the configured period. Reaching a threshold suspends the account.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="strikes"
)
# The booking the strike was issued over, if any — lets the borrower see
# which reservation earned the strike (concept §7.3). Kept on delete so the
# strike survives if the booking is later removed.
booking = models.ForeignKey(
"lending.Booking",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="strikes",
)
reason = models.TextField()
issued_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name="issued_strikes",
)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
class Meta:
ordering = ["-created_at"]
@property
def is_active(self):
return self.expires_at > timezone.now()
def __str__(self):
return f"Strike for {self.user} ({self.created_at:%Y-%m-%d})"