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
-
+
Supprimer
diff --git a/nuxt/components/Dashboard/DUSubProcedureItem.vue b/nuxt/components/Dashboard/DUSubProcedureItem.vue
index 8bca0b559..917405073 100644
--- a/nuxt/components/Dashboard/DUSubProcedureItem.vue
+++ b/nuxt/components/Dashboard/DUSubProcedureItem.vue
@@ -59,7 +59,7 @@
-
+
Supprimer
diff --git a/nuxt/components/Layouts/AppBar.vue b/nuxt/components/Layouts/AppBar.vue
index 66b2e87ed..3ae2a3f6d 100644
--- a/nuxt/components/Layouts/AppBar.vue
+++ b/nuxt/components/Layouts/AppBar.vue
@@ -100,7 +100,20 @@
+ Tableau de bord
+
+