diff --git a/django/core/settings.py b/django/core/settings.py index 25393e315..ba33d39b5 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -37,6 +37,7 @@ "django_extensions", "debug_toolbar", "core", + "users", ] MIDDLEWARE = [ diff --git a/django/tests/conftest.py b/django/tests/conftest.py index 43da56ef5..0082ced21 100644 --- a/django/tests/conftest.py +++ b/django/tests/conftest.py @@ -1,6 +1,8 @@ import pytest from django.db import connection +from users.models import User + @pytest.fixture(scope="session") def django_db_setup(django_db_setup, django_db_blocker, django_db_createdb): # noqa: ANN001, ANN201, ARG001 @@ -24,6 +26,16 @@ def django_db_setup(django_db_setup, django_db_blocker, django_db_createdb): # if not model._meta.managed and not model._meta.db_table.startswith("view_") # noqa: SLF001 ] + # La table `users` est dans le schéma `auth` qui n'est pas listée dans connection.instrospection. + # Traitons-la différemment des autres modèles. + # Voir users/test_models.py + with connection.schema_editor() as schema_editor: + schema_editor.execute("CREATE SCHEMA auth;") + schema_editor.create_model(User) + unmanaged_models_except_views = [ + model for model in unmanaged_models_except_views if model is not User + ] + for model in unmanaged_models_except_views: with connection.schema_editor() as schema_editor: schema_editor.create_model(model) diff --git a/django/tests/users/__init__.py b/django/tests/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/django/tests/users/factories.py b/django/tests/users/factories.py new file mode 100644 index 000000000..7cd834233 --- /dev/null +++ b/django/tests/users/factories.py @@ -0,0 +1,15 @@ +import uuid +from typing import Any + +from tests.factories import _Auto +from users.models import Profile, User + +Auto: Any = _Auto() + + +def create_user_and_profile( + *, email: str = Auto, other_poste: list = Auto +) -> tuple[User, Profile]: + user = User.objects.create(id=uuid.uuid4(), email=email) + profile = Profile.objects.create(user=user, other_poste=other_poste) + return user, profile diff --git a/django/tests/users/test_models.py b/django/tests/users/test_models.py new file mode 100644 index 000000000..3ce67941d --- /dev/null +++ b/django/tests/users/test_models.py @@ -0,0 +1,30 @@ +import uuid + +import pytest + +from users.models import Profile, User + +from .factories import create_user_and_profile + + +@pytest.mark.django_db +def test_user_creation() -> None: + """La table users n'est pas dans le schéma par défaut (public) mais dans `auth`. + + Les schémas PostgreSQL ne semblent pas être très bien pris en charge nativement par Django. + Nous aurions probablement pu mettre en place un routeur de DB (Database Router) pour rester + en cohérence avec l'architecture de Django mais cette table disparaîtra avec Supabase. + Elle existe uniquement car Supabase l'utilise dans sa fonctionnalité SSO. + Pour le moment, et uniquement pour les tests, créons un schéma `auth`. + """ + User.objects.create(id=uuid.uuid4(), email="georges-eugene@haussmann.com") + assert User.objects.count() == 1 + + +@pytest.mark.django_db +def test_profile_creation() -> None: + _, profile = create_user_and_profile( + email="georges-eugene@haussmann.com", other_poste=["rédacteur", "maire"] + ) + assert Profile.objects.count() == 1 + assert profile.other_poste == ["rédacteur", "maire"] diff --git a/django/users/__init__.py b/django/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/django/users/admin.py b/django/users/admin.py new file mode 100644 index 000000000..b5588b492 --- /dev/null +++ b/django/users/admin.py @@ -0,0 +1,33 @@ +# ruff: noqa: ANN001, ARG002 +from typing import Literal + +from django.contrib import admin + +from users.models import Profile, User + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + readonly_fields = ("user",) + search_fields = ("email",) + list_filter = ("poste", "side") + + def has_add_permission(self, request) -> Literal[False]: + return False + + def has_delete_permission(self, request, obj=None) -> Literal[False]: + return False + + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + """Les données sont gérées par Supabase.""" + + def has_delete_permission(self, request, obj=None) -> Literal[False]: + return False + + def has_change_permission(self, request, obj=None) -> Literal[False]: + return False + + def has_add_permission(self, request) -> Literal[False]: + return False diff --git a/django/users/apps.py b/django/users/apps.py new file mode 100644 index 000000000..80982c68d --- /dev/null +++ b/django/users/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" + verbose_name = "Utilisateurs" diff --git a/django/users/enums.py b/django/users/enums.py new file mode 100644 index 000000000..37ad896bf --- /dev/null +++ b/django/users/enums.py @@ -0,0 +1,41 @@ +from django.db import models + + +class ProfileSideType(models.TextChoices): + ETAT = "etat", "État" + COLLECTIVITE = "collectivite", "Collectivité" + PPA = "ppa", "PPA" + + +class PosteType(models.TextChoices): + # Postes État + # https://github.com/MTES-MCT/Docurba/blob/2050c8dc046f9c8414ebe9aaf72a3f3cdbc623d2/nuxt/plugins/utils.js#L58 + DDT = "ddt", "DDT(M)/DEAL" + DREAL = "dreal", "DREAL" + + # Postes PPA + REGION = "region", "Région" + + # Postes Collectivités + BE = "be", "Bureau d'études" + ELU = "elu", "Collectivité, Élu·e" + EMPLOYE_MAIRIE = "employe_mairie", "Collectivité, Technicien·ne ou employé·e" + AGENCE_URBA = "agence_urba", "Agence d'urbanisme" + AUTRE = "autre", "Autre" + + +class RoleType(models.TextChoices): + # Nuxt : + # Côté collectivité, c'est un InputField qui ne valide pas l'input. + # A contrario, côté État, c'est un TextChoices avec un menu déroulant. U_U + # https://github.com/MTES-MCT/Docurba/blob/2050c8dc046f9c8414ebe9aaf72a3f3cdbc623d2/nuxt/plugins/utils.js#L62 + # Cela étant, il semblerait que les utilisateurs n'aient pas été très inspirés car seules 33 + # valeurs différentes figurent en base (en excluant les comptes tests), dont des "PPA". + # Rôles état + CHEF_UNITE = "chef_unite", "Chef·fe d'unité/de bureau/de service et adjoint·e" + REDACTEUR_PAC = "redacteur_pac", "Rédacteur·ice de PAC" + SUIVI_PROCEDURES = ( + "suivi_procedures", + "Chargé·e de l'accompagnement des collectivités", + ) + REFERENT_SUDOCUH = "referent_sudocuh", "Référent·e Sudocuh" diff --git a/django/users/migrations/0001_initial.py b/django/users/migrations/0001_initial.py new file mode 100644 index 000000000..22add0abe --- /dev/null +++ b/django/users/migrations/0001_initial.py @@ -0,0 +1,197 @@ +# ruff: noqa: RUF012 +# Generated by Django 5.1.5 on 2026-01-08 13:44 + +import django.contrib.postgres.fields +import django.db.models.deletion +import django.db.models.functions.datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("id", models.UUIDField(primary_key=True, serialize=False)), + ( + "email", + models.EmailField(blank=True, max_length=254, verbose_name="Email"), + ), + ], + options={ + "verbose_name": "utilisateur", + "managed": False, + "db_table": '"auth"."users"', + }, + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="users.user", + verbose_name="Utilisateur", + ), + ), + ( + "created_at", + models.DateTimeField( + db_default=django.db.models.functions.datetime.Now(), + verbose_name="Date de création", + ), + ), + ( + "firstname", + models.CharField(blank=True, null=True, verbose_name="Prénom"), + ), + ( + "lastname", + models.CharField(blank=True, null=True, verbose_name="Nom"), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, null=True, verbose_name="Email" + ), + ), + ( + "poste", + models.CharField( + blank=True, + choices=[ + ("ddt", "DDT(M)/DEAL"), + ("dreal", "DREAL"), + ("region", "Région"), + ("be", "Bureau d'études"), + ("elu", "Collectivité, Élu·e"), + ( + "employe_mairie", + "Collectivité, Technicien·ne ou employé·e", + ), + ("agence_urba", "Agence d'urbanisme"), + ("autre", "Autre"), + ], + null=True, + verbose_name="Poste", + ), + ), + ( + "other_poste", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + blank=True, + choices=[ + ( + "chef_unite", + "Chef·fe d'unité/de bureau/de service et adjoint·e", + ), + ("redacteur_pac", "Rédacteur·ice de PAC"), + ( + "suivi_procedures", + "Chargé·e de l'accompagnement des collectivités", + ), + ("referent_sudocuh", "Référent·e Sudocuh"), + ], + ), + blank=True, + null=True, + size=None, + verbose_name="Role(s)", + ), + ), + ( + "departement", + models.CharField(blank=True, null=True, verbose_name="Département"), + ), + ( + "collectivite_id", + models.CharField( + blank=True, null=True, verbose_name="Code Collectivité" + ), + ), + ( + "tel", + models.CharField(blank=True, null=True, verbose_name="Téléphone"), + ), + ( + "verified", + models.BooleanField(default=False, verbose_name="Vérifié"), + ), + ( + "side", + models.CharField( + blank=True, + choices=[ + ("etat", "État"), + ("collectivite", "Collectivité"), + ("ppa", "PPA"), + ], + null=True, + verbose_name="Side", + ), + ), + ( + "region", + models.CharField( + blank=True, + db_comment="Si l'utilisateur est une DREAL ou une PPA : région de son périmètre. La colonne peut être remplie pour les autres types.", + null=True, + verbose_name="Région", + ), + ), + ( + "no_signup", + models.BooleanField( + db_comment="Si l'utilisateur passe par un depot d'acte, il est autorisé à mettre uniquement son email. Dans ce cas l'utilisateur n'a pas de compte Docurba, et no_signup sera à TRUE.", + default=False, + verbose_name="Dépôt d'actes (no_signup)", + ), + ), + ( + "successfully_logged_once", + models.BooleanField( + default=False, verbose_name="S'est déjà connecté" + ), + ), + ( + "optin", + models.BooleanField( + default=False, verbose_name="Est inscrit à l'infolettre" + ), + ), + ( + "updated_pipedrive", + models.BooleanField( + default=False, verbose_name="Est mis à jour sur Pipedrive" + ), + ), + ( + "is_admin", + models.BooleanField( + db_comment="super admin bypass", + default=False, + verbose_name="Est admin (is_admin)", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, verbose_name="Est membre (is_staff)" + ), + ), + ], + options={ + "verbose_name": "profil", + "db_table": "profiles", + "managed": False, + }, + ), + ] diff --git a/django/users/migrations/0002_add_column_departements.py b/django/users/migrations/0002_add_column_departements.py new file mode 100644 index 000000000..95d1f28f9 --- /dev/null +++ b/django/users/migrations/0002_add_column_departements.py @@ -0,0 +1,25 @@ +# ruff: noqa: RUF012 +# Generated by Django 5.1.5 on 2025-12-03 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.RunSQL( + sql=[ + ( + "ALTER TABLE IF EXISTS public.profiles ADD COLUMN IF NOT EXISTS departements text[]" + ) + ], + reverse_sql=[ + ( + "ALTER TABLE IF EXISTS public.profiles DROP COLUMN IF EXISTS departements" + ) + ], + ) + ] diff --git a/django/users/migrations/__init__.py b/django/users/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/django/users/models.py b/django/users/models.py new file mode 100644 index 000000000..404e360e2 --- /dev/null +++ b/django/users/models.py @@ -0,0 +1,117 @@ +# ruff: noqa: DJ001 + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.db.models.functions import Now + +from users import enums as users_enums + + +class User(models.Model): + """L'authentification est gérée par la fonctionnalité SSO de Supabase. + + Le schéma de cette table est géré par Supabase. Seules les colonnes intéressantes sont + référencées ici. + Un jour, nous pourrons utiliser le User model de Django. Pour l'instant, documentons l'existant. + """ + + id = models.UUIDField(primary_key=True) + email = models.EmailField(verbose_name="Email", blank=True) + + class Meta: + managed = False + db_table = '"auth"."users"' + verbose_name = "utilisateur" + + def __str__(self) -> str: + return self.email + + +class Profile(models.Model): + """La table `users` étant gérée par Supabase, les informations supplémentaires concernant l'utilisateur sont conservées ici.""" + + user = models.OneToOneField( + User, verbose_name="Utilisateur", on_delete=models.CASCADE, primary_key=True + ) + created_at = models.DateTimeField(verbose_name="Date de création", db_default=Now()) + # La plupart des CharField peuvent avoir une valeur nulle ou "" en base car + # il n'y a pas de restriction. Il faudrait changer ce comportement. + # Pour l'instant, documentons. + firstname = models.CharField(verbose_name="Prénom", blank=True, null=True) + lastname = models.CharField(verbose_name="Nom", blank=True, null=True) + email = models.EmailField(verbose_name="Email", blank=True, null=True) + poste = models.CharField( + verbose_name="Poste", + choices=users_enums.PosteType, + blank=True, + null=True, + ) + other_poste = ArrayField( + verbose_name="Role(s)", + base_field=models.CharField(choices=users_enums.RoleType, blank=True), + blank=True, + null=True, + ) + # Ce devrait être une clé étrangère pointant vers la table departements + # mais ce n'est actuellement pas le cas en base et dans Nuxt. + departement = models.CharField(verbose_name="Département", blank=True, null=True) + # Les utilisateurs side PPA peuvent voir les départements listés dans cette colonne. + # Il s'agit d'une rustine temportaire pour relier les utilisateurs à plusieurs départements + # (et non un seul comme c'est le cas actuellement avec la colonne `departement`). + departements = ArrayField( + verbose_name="Départements", + # Charfield pour permettre aux départements ne contenant qu'un chiffre de commencer par un zéro. + base_field=models.CharField(max_length=3, blank=True), + blank=True, + null=True, + ) + # Ce devrait être une clé étrangère pointant vers Collectivite mais la colonne collectivite_id + # ne conserve pas la clé primaire déclarée pour le modèle (i.e. au format f"{code_insee}_{type}") + # mais seulement le "code_insee". + collectivite_id = models.CharField( + verbose_name="Code Collectivité", blank=True, null=True + ) + tel = models.CharField(verbose_name="Téléphone", blank=True, null=True) + verified = models.BooleanField(verbose_name="Vérifié", default=False) + # La valeur `blank` ne devrait pas être autorisée car un utilisateur devrait toujours + # avoir un `side` mais c'est le cas aujourd'hui en base. + side = models.CharField( + verbose_name="Side", choices=users_enums.ProfileSideType, blank=True, null=True + ) + # Ce devrait être une clé étrangère pointant vers la table regions + # mais ce n'est actuellement pas le cas en base et dans Nuxt. + region = models.CharField( + verbose_name="Région", + blank=True, + null=True, + db_comment="Si l'utilisateur est une DREAL ou une PPA : région de son périmètre. La colonne peut être remplie pour les autres types.", + ) + no_signup = models.BooleanField( + verbose_name="Dépôt d'actes (no_signup)", + default=False, + db_comment="Si l'utilisateur passe par un depot d'acte, il est autorisé à mettre uniquement son email. Dans ce cas l'utilisateur n'a pas de compte Docurba, et no_signup sera à TRUE.", + ) + successfully_logged_once = models.BooleanField( + verbose_name="S'est déjà connecté", default=False + ) + optin = models.BooleanField( + verbose_name="Est inscrit à l'infolettre", default=False + ) + updated_pipedrive = models.BooleanField( + verbose_name="Est mis à jour sur Pipedrive", + default=False, + ) + is_admin = models.BooleanField( + verbose_name="Est admin (is_admin)", + default=False, + db_comment="super admin bypass", + ) + is_staff = models.BooleanField(verbose_name="Est membre (is_staff)", default=False) + + class Meta: + managed = False + db_table = "profiles" + verbose_name = "profil" + + def __str__(self) -> str: + return f"{self.firstname} {self.lastname}" diff --git a/nuxt/components/Dashboard/DUItemsList.vue b/nuxt/components/Dashboard/DUItemsList.vue index 21c379e5c..3031fc02a 100644 --- a/nuxt/components/Dashboard/DUItemsList.vue +++ b/nuxt/components/Dashboard/DUItemsList.vue @@ -15,7 +15,7 @@ SCoT - + Ajouter une procédure @@ -78,7 +78,7 @@ Cette collectivité n'a pas de documents d'urbanisme sous sa compétence. - + Ajouter une procédure diff --git a/nuxt/components/Dashboard/DUProcedureItem.vue b/nuxt/components/Dashboard/DUProcedureItem.vue index 638c483cb..a35eeaf96 100644 --- a/nuxt/components/Dashboard/DUProcedureItem.vue +++ b/nuxt/components/Dashboard/DUProcedureItem.vue @@ -65,7 +65,7 @@ PAC - +