Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions django/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"django_extensions",
"debug_toolbar",
"core",
"users",
]

MIDDLEWARE = [
Expand Down
12 changes: 12 additions & 0 deletions django/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
Empty file added django/tests/users/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions django/tests/users/factories.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions django/tests/users/test_models.py
Original file line number Diff line number Diff line change
@@ -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"]
Empty file added django/users/__init__.py
Empty file.
33 changes: 33 additions & 0 deletions django/users/admin.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions django/users/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
verbose_name = "Utilisateurs"
41 changes: 41 additions & 0 deletions django/users/enums.py
Original file line number Diff line number Diff line change
@@ -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"
197 changes: 197 additions & 0 deletions django/users/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
25 changes: 25 additions & 0 deletions django/users/migrations/0002_add_column_departements.py
Original file line number Diff line number Diff line change
@@ -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"
)
],
)
]
Empty file.
Loading