-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathuser.py
More file actions
358 lines (310 loc) · 14.6 KB
/
Copy pathuser.py
File metadata and controls
358 lines (310 loc) · 14.6 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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
from dirtyfields import DirtyFieldsMixin
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Count, F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django_otp.plugins.otp_totp.models import TOTPDevice
from common.utils import utils as utils_utils
from data.fields import ChoiceArrayField
from data.models.geo import Department
from data.utils import optimize_image
from data.validators import user as user_validators
from macantine import brevo
def canteen_not_deleted_query():
return Q(canteens__deletion_date=None)
class UserQuerySet(models.QuerySet):
def brevo_to_create(self):
return self.filter(brevo_last_update_date__isnull=True)
def brevo_to_update(self):
one_day_ago = timezone.now() - timezone.timedelta(days=brevo.CONTACT_BULK_UPDATE_LAST_UPDATED_THRESHOLD_DAYS)
return self.exclude(brevo_is_deleted=True).filter(brevo_last_update_date__lte=one_day_ago)
def with_canteen_stats(self):
from data.models import Canteen
return self.prefetch_related("canteens").annotate(
nb_cantines=Count("canteens", filter=canteen_not_deleted_query(), distinct=True),
nb_cantines_groupe=Count(
"canteens",
filter=canteen_not_deleted_query()
& Q(
canteens__production_type__in=[
Canteen.ProductionType.GROUPE,
Canteen.ProductionType.CENTRAL,
Canteen.ProductionType.CENTRAL_SERVING,
]
),
distinct=True,
),
nb_cantines_site=Count(
"canteens",
filter=canteen_not_deleted_query() & Q(canteens__production_type=Canteen.ProductionType.ON_SITE),
distinct=True,
),
nb_cantines_satellite=Count(
"canteens",
filter=canteen_not_deleted_query()
& Q(canteens__production_type=Canteen.ProductionType.ON_SITE_CENTRAL),
distinct=True,
),
nb_cantines_gestion_concedee=Count(
"canteens",
filter=canteen_not_deleted_query() & Q(canteens__management_type=Canteen.ManagementType.CONCEDED),
distinct=True,
),
)
def with_canteen_diagnostic_stats(self):
return self.prefetch_related("canteens", "canteens__diagnostics").annotate(
# bilans
nb_cantines_bilan_2025=Count(
"canteens__diagnostics",
filter=canteen_not_deleted_query() & Q(canteens__diagnostics__year=2025),
distinct=True,
),
nb_cantines_bilan_todo_2025=Count("canteens", filter=canteen_not_deleted_query(), distinct=True)
- F("nb_cantines_bilan_2025"),
# TDs
nb_cantines_td_2025=Count(
"canteens",
filter=canteen_not_deleted_query() & Q(canteens__declaration_donnees_2025=True),
distinct=True,
),
nb_cantines_td_todo_2025=Count("canteens", filter=canteen_not_deleted_query(), distinct=True)
- F("nb_cantines_td_2025"),
)
class UserManager(BaseUserManager):
pass
class User(DirtyFieldsMixin, AbstractUser):
class LawAwareness(models.TextChoices):
NONE = (
"NONE",
"Je n’ai pas une connaissance détaillée de l’article 24 de la loi EGalim",
)
AIMS_DEADLINES = "AIMS_DEADLINES", "Je connais les objectifs et les échéances"
ELIGIBLE_LABELS = "ELIGIBLE_LABELS", "Je connais la liste des labels éligibles et mentions valorisantes"
MY_LABELS = (
"MY_LABELS",
"J’ai accès aux informations ou mon prestataire me fournit les informations sur les labels éligibles et mentions valorisantes concernant mes achats",
)
SYSTEM = (
"SYSTEM",
"J’ai un système de saisie formalisé (SI, Excel, papier) permettant de calculer et reporter le montant annuel de mes achats répondants aux exigences de l’article 24 de la loi EGalim (Non applicable en gestion concédée)",
)
TAKEN_STOCK = (
"TAKEN_STOCK",
"J’ai réalisé un état des lieux précis de mes approvisionnements",
)
OPTION_DIAGNOSTIC = (
"OPTION_DIAGNOSTIC",
"J’ai réalisé un diagnostic de l’offre (disponibilité et caractéristiques de l’offre des différents fournisseurs sur l’ensemble des catégories d’achats)",
)
ACTION_PLAN = (
"ACTION_PLAN",
"J’ai établi un plan d’actions pour tendre vers les objectifs de la loi EGalim, définissant notamment : le niveau d’ambition global et par catégories d’achats ; les échéances de renouvellement de contrat avec clauses EGalim ; le phasage de la progression des indicateurs EGalim",
)
QUALITY_ACHIEVED = (
"QUALITY_ACHIEVED",
"J'ai atteint les objectifs - 50% et 20%, de l’article 24 de la loi EGalim",
)
class JobRoles(models.TextChoices):
ESTABLISHMENT_MANAGER = "ESTABLISHMENT_MANAGER", "Gestionnaire d'établissement"
CATERING_PURCHASES_MANAGER = "CATERING_PURCHASES_MANAGER", "Direction Achat Société de restauration"
DIRECT_PURCHASES_MANAGER = "DIRECT_PURCHASES_MANAGER", "Responsable d'achats en gestion directe"
CENTRAL_MANAGER = "CENTRAL_MANAGER", "Responsable de plusieurs établissements (type cuisine centrale)"
MANY_ESTABLISHMENTS_MANAGER = "MANY_ESTABLISHMENTS_MANAGER", "Responsable de plusieurs établissements (SRC)"
TECHNICAL = "TECHNICAL", "Développement logiciel, analyse de données ou autre rôle technique"
OTHER = "OTHER", "Autre"
class Sources(models.TextChoices):
WEBINAIRE = "WEBINAIRE", "Webinaire"
WEB_SEARCH = "WEB_SEARCH", "Recherche web"
INSTITUTION = "INSTITUTION", "Communication institutionnelle (DRAAF, association régionale)"
WORD_OF_MOUTH = "WORD_OF_MOUTH", "Bouche à oreille"
SOCIAL_MEDIA = "SOCIAL_MEDIA", "Réseaux sociaux"
OTHER = "OTHER", "Autre (spécifiez)"
MATOMO_FIELDS = [
"creation_mtm_source",
"creation_mtm_campaign",
"creation_mtm_medium",
]
BREVO_FIELDS = [
"brevo_last_update_date",
"brevo_is_deleted",
]
DATA_CANTEEN_FIELDS = [
"nb_cantines",
"nb_cantines_site",
"nb_cantines_satellite",
"nb_cantines_groupe",
"nb_cantines_gestion_concedee",
]
DATA_CANTEEN_DIAGNOSTIC_FIELDS = [
"nb_cantines_bilan_2025",
"nb_cantines_bilan_todo_2025",
"nb_cantines_td_2025",
"nb_cantines_td_todo_2025",
]
avatar = models.ImageField("Photo de profil", null=True, blank=True)
email = models.EmailField(_("email address"), unique=True)
email_confirmed = models.BooleanField(default="False", verbose_name="adresse email confirmée")
law_awareness = ChoiceArrayField(
base_field=models.CharField(max_length=255, choices=LawAwareness.choices),
blank=True,
null=True,
size=None,
verbose_name="Les affirmations suivantes concernent l'article 24 de la loi EGalim, encadrant les objectifs d'approvisionnements (50% de produits durables et de qualité dont 20% de bio). Parmi ces affirmations, plusieurs choix sont possibles. Choisissez celles qui correspondent à votre situation :",
)
is_dev = models.BooleanField(default="False", verbose_name="Compte développeur / technique")
is_elected_official = models.BooleanField(default="False", verbose_name="Compte élu·e")
# MonComptePro
created_with_mcp = models.BooleanField(default="False", verbose_name="Compte créé avec MonComptePro")
mcp_id = models.TextField(blank=True, null=True, verbose_name="ID MonComptePro")
mcp_organizations = models.JSONField(blank=True, null=True, verbose_name="Organisations sous MonComptePro")
phone_number = models.CharField("Numéro téléphone", max_length=50, null=True, blank=True)
job = models.CharField(
max_length=255,
choices=JobRoles.choices,
blank=True,
null=True,
verbose_name="Fonction",
)
other_job_description = models.TextField(
null=True,
blank=True,
verbose_name="autre fonction détail",
)
source = models.CharField(
max_length=255,
choices=Sources.choices,
blank=True,
null=True,
verbose_name="Comment est-ce que la personne a connu ma cantine ?",
)
other_source_description = models.TextField(
null=True,
blank=True,
verbose_name="autre source détail",
)
# Campaign tracking
creation_mtm_source = models.TextField(
null=True, blank=True, verbose_name="mtm_source du lien tracké lors de la création"
)
creation_mtm_campaign = models.TextField(
null=True, blank=True, verbose_name="mtm_campaign du lien tracké lors de la création"
)
creation_mtm_medium = models.TextField(
null=True, blank=True, verbose_name="mtm_medium du lien tracké lors de la création"
)
# Geographical scope for elected profiles
departments = ChoiceArrayField(
base_field=models.CharField(max_length=255, choices=Department.choices),
blank=True,
null=True,
size=None,
verbose_name="departements où l'élu·e peut regarder les cantines",
)
# Emails
opt_out_reminder_emails = models.BooleanField(default="False", verbose_name="Désactiver les emails de rappel")
brevo_last_update_date = models.DateTimeField(
null=True, blank=True, verbose_name="Date de la dernière mise à jour Brevo"
)
brevo_is_deleted = models.BooleanField(default=False, verbose_name="Indique si le contact a été supprimé de Brevo")
data = models.JSONField(blank=True, null=True, verbose_name="Données calculées")
# Django fields
# last_login, date_joined, is_active, is_staff, is_superuser
objects = UserManager.from_queryset(UserQuerySet)()
def __str__(self):
return f"{self.get_full_name()} ({self.username})"
def normalize_fields(self):
for field_name in ["email", "username"]:
if field_name in self.get_dirty_fields():
setattr(self, field_name, utils_utils.normalize_string(getattr(self, field_name)))
def lowercase_fields(self):
for field_name in ["email", "username"]:
if field_name in self.get_dirty_fields():
value = getattr(self, field_name)
if value:
setattr(self, field_name, value.lower())
def optimize_avatar(self):
max_avatar_size = 640
if self.avatar:
self.avatar = optimize_image(self.avatar, self.avatar.name, max_avatar_size)
def reset_brevo_fields_if_email_changed(self):
"""
Cases where we need to reset Brevo fields:
- if email has changed: reset all Brevo fields (to recreate contact)
"""
if self.id and self.is_dirty():
if "email" in self.get_dirty_fields():
self.reset_brevo_fields(with_save=False)
def save(self, skip_validations=False, **kwargs):
self.normalize_fields()
self.lowercase_fields()
self.optimize_avatar()
self.reset_brevo_fields_if_email_changed()
if not skip_validations:
self.full_clean(exclude=["password"])
super().save(**kwargs)
def clean(self, *args, **kwargs):
validation_errors = utils_utils.merge_validation_errors(
user_validators.validate_user_non_staff(self),
user_validators.validate_user_superuser(self),
)
if validation_errors:
raise ValidationError(validation_errors)
@property
def has_mtm_data(self):
return self.creation_mtm_source or self.creation_mtm_campaign or self.creation_mtm_medium
def canteens_count(self):
return self.canteens.count()
def has_canteens(self):
return self.canteens.exists()
def has_missing_diagnostic_for_year(self, year):
return self.canteens.exists() and any(not x.has_diagnostic_for_year(year) for x in self.canteens.all())
def has_missing_teledeclaration_for_year(self, year):
return self.canteens.exists() and any(
not x.has_diagnostic_teledeclared_for_year(year) for x in self.canteens.all()
)
def reset_brevo_fields(self, with_save=True):
self.brevo_last_update_date = None
self.brevo_is_deleted = False
if with_save:
self.save()
def update_data(self):
# need to have called the user with 'with_canteen_stats' & 'with_canteen_diagnostic_stats' queryset method
self.data = {
field: getattr(self, field) for field in self.DATA_CANTEEN_FIELDS + self.DATA_CANTEEN_DIAGNOSTIC_FIELDS
}
self.data["modification_date"] = timezone.now().isoformat()
self.save(update_fields=["data"])
def get_brevo_data(self):
"""
See macantine/brevo.py for usage.
"""
data_canteen_fields_dict = {
f"MA_CANTINE_{field.upper()}": self.data.get(field, 0) if self.data else 0
for field in self.DATA_CANTEEN_FIELDS
}
data_canteen_diagnostic_fields_dict = {
f"MA_CANTINE_{field.upper()}": self.data.get(field, 0) if self.data else 0
for field in self.DATA_CANTEEN_DIAGNOSTIC_FIELDS
}
return {
# user info
"NOM": self.last_name,
"PRENOM": self.first_name,
"NOM_COMPLET": self.get_full_name(),
"MA_CANTINE_DATE_INSCRIPTION": self.date_joined.strftime("%Y-%m-%d"),
"MA_CANTINE_DERNIERE_CONNEXION": self.last_login.strftime("%Y-%m-%d") if self.last_login else "",
"MA_CANTINE_COMPTE_DEV": self.is_dev,
"MA_CANTINE_COMPTE_ELU_E": self.is_elected_official,
# user canteen & diagnostic info
**data_canteen_fields_dict,
**data_canteen_diagnostic_fields_dict,
}
@receiver(pre_save, sender=TOTPDevice)
def validate_totp_device_user_is_staff(sender, instance, **kwargs):
if instance.user and not instance.user.is_staff:
raise ValidationError("Seul les utilisateurs staff sont autorisés à configurer un appareil 2FA (OTP).")