Bläddra i källkod

Merge branch 'v2' of clovis/bdlg2023-sms-back into master

clovis 1 dag sedan
förälder
incheckning
14645a0dbf
73 ändrade filer med 8300 tillägg och 2678 borttagningar
  1. 10 0
      .env.example
  2. 8 0
      .idea/.gitignore
  3. 19 0
      .idea/bdlg2023-sms-back.iml
  4. 5 0
      .idea/codeStyles/codeStyleConfig.xml
  5. 6 0
      .idea/inspectionProfiles/profiles_settings.xml
  6. 7 0
      .idea/misc.xml
  7. 8 0
      .idea/modules.xml
  8. 16 0
      .idea/pyLspTools.xml
  9. 6 0
      .idea/ruff.xml
  10. 6 0
      .idea/vcs.xml
  11. 0 31
      Dockerfile
  12. 28 5
      Readme.md
  13. 45 0
      alembic/versions/2026072143_add_missing_relation_f7a9944a660c.py
  14. 149 0
      alembic/versions/2026072155_implement_commission_and_organisation_285f2f9c840a.py
  15. 55 0
      alembic/versions/2026072531_finalize_authorisation_f5fc28123cc0.py
  16. 36 0
      alembic/versions/2026072549_add_timestamp_to_commission_b9072f955a8d.py
  17. 32 0
      alembic/versions/2026072635_add_must_change_password_to_user_3b96bd12a533.py
  18. 51 0
      alembic/versions/2026072652_add_password_reset_request_log_a2c893432a1f.py
  19. 52 0
      alembic/versions/2026072729_persist_project_constraints_f647c6bd94ae.py
  20. 43 0
      app/api/SSEBroadcasterRoute.py
  21. 11 3
      app/api/api.py
  22. 127 14
      app/api/deps.py
  23. 229 0
      app/api/endpoints/commissions.py
  24. 182 0
      app/api/endpoints/organizations.py
  25. 93 52
      app/api/endpoints/project.py
  26. 49 0
      app/api/endpoints/project_stream.py
  27. 49 27
      app/api/endpoints/slots.py
  28. 18 13
      app/api/endpoints/sms.py
  29. 13 14
      app/api/endpoints/sms_sender.py
  30. 16 12
      app/api/endpoints/tags.py
  31. 40 16
      app/api/endpoints/templates.py
  32. 105 7
      app/api/endpoints/users.py
  33. 198 0
      app/api/endpoints/volunteer_groups.py
  34. 24 21
      app/api/endpoints/volunteers.py
  35. 69 1
      app/api/utils.py
  36. 16 2
      app/core/config.py
  37. 63 0
      app/core/email.py
  38. 6 0
      app/core/events.py
  39. 36 3
      app/core/security.py
  40. 3 3
      app/create_sms_batch.py
  41. 4 3
      app/initial_data.py
  42. 101 1
      app/main.py
  43. 202 18
      app/models.py
  44. 13 0
      app/schemas/objects.py
  45. 129 45
      app/schemas/requests.py
  46. 66 12
      app/schemas/responses.py
  47. 158 22
      app/tests/conftest.py
  48. 96 0
      app/tests/shared_access.py
  49. 535 0
      app/tests/test_commissions.py
  50. 527 0
      app/tests/test_organizations.py
  51. 184 0
      app/tests/test_password_reset.py
  52. 0 240
      app/tests/test_project.py
  53. 309 0
      app/tests/test_projects.py
  54. 86 0
      app/tests/test_projects_stream.py
  55. 0 425
      app/tests/test_slot.py
  56. 681 0
      app/tests/test_slots.py
  57. 290 252
      app/tests/test_sms.py
  58. 504 281
      app/tests/test_sms_batch.py
  59. 266 0
      app/tests/test_sms_sender.py
  60. 0 303
      app/tests/test_tag.py
  61. 409 0
      app/tests/test_tags.py
  62. 0 250
      app/tests/test_template.py
  63. 488 0
      app/tests/test_templates.py
  64. 241 55
      app/tests/test_users.py
  65. 0 357
      app/tests/test_volunteer.py
  66. 418 0
      app/tests/test_volunteer_groups.py
  67. 406 0
      app/tests/test_volunteers.py
  68. 0 36
      docker-compose.dev.yml
  69. 0 40
      docker-compose.yml
  70. 222 92
      poetry.lock
  71. 12 5
      pyproject.toml
  72. 13 10
      requirements-dev.txt
  73. 11 7
      requirements.txt

+ 10 - 0
.env.example

@@ -1,3 +1,4 @@
+FRONT_END_URL="http://localhost:8000"
 SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG
 ENVIRONMENT=DEV
 ACCESS_TOKEN_EXPIRE_MINUTES=11520
@@ -5,6 +6,8 @@ REFRESH_TOKEN_EXPIRE_MINUTES=40320
 BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8001"]
 ALLOWED_HOSTS=["localhost", "127.0.0.1"]
 
+REDIS_URL=redis://localhost:6379
+
 DEFAULT_DATABASE_HOSTNAME=localhost
 DEFAULT_DATABASE_USER=rDGJeEDqAz
 DEFAULT_DATABASE_PASSWORD=XsPQhCoEfOQZueDjsILetLDUvbvSxAMnrVtgVZpmdcSssUgbvs
@@ -22,3 +25,10 @@ BATCH_SMS_PHONE_NUMBER="01 10 10 10 10"
 
 FIRST_SUPERUSER_EMAIL=example@example.com
 FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC
+
+SMTP_HOST=""
+SMTP_PORT=587
+SMTP_USERNAME=""
+SMTP_PASSWORD=""
+EMAIL_FROM_ADDRESS="no-reply@example.com"
+EMAIL_FROM_NAME="BDLG Planner"

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 19 - 0
.idea/bdlg2023-sms-back.iml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+  <component name="NewModuleRootManager">
+    <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
+      <excludeFolder url="file://$MODULE_DIR$/.venv" />
+      <excludeFolder url="file://$MODULE_DIR$/venv" />
+    </content>
+    <orderEntry type="jdk" jdkName="Python 3.11 (bdlg2023-sms-back)" jdkType="Python SDK" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+  <component name="PyDocumentationSettings">
+    <option name="format" value="PLAIN" />
+    <option name="myDocStringFormat" value="Plain" />
+  </component>
+  <component name="TestRunnerService">
+    <option name="PROJECT_TEST_RUNNER" value="py.test" />
+  </component>
+</module>

+ 5 - 0
.idea/codeStyles/codeStyleConfig.xml

@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
+  </state>
+</component>

+ 6 - 0
.idea/inspectionProfiles/profiles_settings.xml

@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+  <settings>
+    <option name="USE_PROJECT_PROFILE" value="false" />
+    <version value="1.0" />
+  </settings>
+</component>

+ 7 - 0
.idea/misc.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Black">
+    <option name="sdkName" value="Python 3.11 (bdlg2023-sms-back)" />
+  </component>
+  <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (bdlg2023-sms-back)" project-jdk-type="Python SDK" />
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/bdlg2023-sms-back.iml" filepath="$PROJECT_DIR$/.idea/bdlg2023-sms-back.iml" />
+    </modules>
+  </component>
+</project>

+ 16 - 0
.idea/pyLspTools.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="PyToolsState">
+    <option name="tools">
+      <map>
+        <entry key="ruff">
+          <value>
+            <ToolEntry>
+              <option name="enabled" value="true" />
+            </ToolEntry>
+          </value>
+        </entry>
+      </map>
+    </option>
+  </component>
+</project>

+ 6 - 0
.idea/ruff.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RuffConfigService">
+    <option name="useRuffServer" value="true" />
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>

+ 0 - 31
Dockerfile

@@ -1,31 +0,0 @@
-FROM python:3.11.1-slim-bullseye
-
-ENV PYTHONUNBUFFERED 1
-WORKDIR /build
-
-# Create venv, add it to path and install requirements
-RUN python -m venv /venv
-ENV PATH="/venv/bin:$PATH"
-
-COPY requirements.txt .
-RUN pip install -r requirements.txt
-
-# Install uvicorn server
-RUN pip install uvicorn[standard]
-
-# Copy the rest of app
-COPY app app
-COPY alembic alembic
-COPY alembic.ini .
-COPY pyproject.toml .
-COPY init.sh .
-
-# Create new user to run app process as unprivilaged user
-RUN addgroup --gid 1001 --system uvicorn && \
-    adduser --gid 1001 --shell /bin/false --disabled-password --uid 1001 uvicorn
-
-# Run init.sh script then start uvicorn
-RUN chown -R uvicorn:uvicorn /build
-CMD bash init.sh && \
-    runuser -u uvicorn -- /venv/bin/uvicorn app.main:app --app-dir /build --host 0.0.0.0 --port 8000 --workers 2 --loop uvloop
-EXPOSE 8000

+ 28 - 5
Readme.md

@@ -1,6 +1,27 @@
 # bdlg-2023-server
 
-This repository host an API that can parse gsheet planning for brass dans la garonne event and manage creating automatic SMS notification for volunteer.
+This repository hosts an API that helps volunteer managers plan festival activities on a schedule, associate volunteers
+to time slots, and send SMS reminders during the event. Volunteer/slot data can also be bootstrapped from a Google Sheet
+import.
+
+## Core concepts
+
+- **Organization** — the top-level tenant. Every user belongs to zero or more organizations (multi-org membership is
+  supported). Every project belongs to exactly one organization, which defines who can access it.
+- **Global roles** — `user` (default) or `super_admin`. Only `super_admin`
+  can create/edit organizations and manage organization membership.
+- **Organization roles** (per user, per organization):
+    - `org_admin` — full control over the organization's projects and can change member roles within their own
+      organization.
+    - `respo_benevole` — manages volunteer allocation across the whole project: slots, templates, volunteers, groups,
+      and SMS.
+    - `respo_commission` — read access to the whole project plan; write access limited to templates/slots belonging to
+      the commission (s) they are a member of. Cannot manage volunteers, groups, or SMS directly.
+- **Commission** (Pôle) — represents a team/area within a project (e.g.
+  "Bar", "Accueil"). A commission's contact info is derived from its members' own profile (`name` + `phone_number`)
+  rather than stored as free text — see `SlotTemplate.responsible_override` for the manual exception case.
+- **Volunteer group** — a saved, searchable set of volunteers within a project, usable for bulk slot assignment and
+  group SMS.
 
 ## Getting started
 
@@ -34,11 +55,13 @@ Run specific tests
 ## Update requirements
 
 ```
-> poetry lock
-> poetry export -f requirements.txt --output requirements.txt --without-hashes
-> poetry export -f requirements.txt --output requirements-dev.txt --without-hashes --with dev
+poetry lock
+poetry export -f requirements.txt --output requirements.txt --without-hashes
+poetry export -f requirements.txt --output requirements-dev.txt --without-hashes --with dev
 ```
 
+Regenerate after any backend route or schema change — see the frontend repo's README for details.
+
 ## Credit
 
-FastAPI project generated using https://github.com/rafsaf/minimal-fastapi-postgres-template
+FastAPI project generated using https://github.com/rafsaf/minimal-fastapi-postgres-template

+ 45 - 0
alembic/versions/2026072143_add_missing_relation_f7a9944a660c.py

@@ -0,0 +1,45 @@
+"""add missing relation
+
+Revision ID: f7a9944a660c
+Revises: 285f2f9c840a
+Create Date: 2026-07-21 01:43:31.818040
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f7a9944a660c'
+down_revision = '285f2f9c840a'
+branch_labels = None
+depends_on = None
+
+
+
+def upgrade():
+    op.add_column('slot_templates', sa.Column('commission_id', sa.UUID(as_uuid=False), nullable=True))
+    op.create_foreign_key(
+        'fk_slot_templates_commission_id',
+        'slot_templates', 'commissions',
+        ['commission_id'], ['id'],
+        ondelete='SET NULL',
+    )
+    op.alter_column(
+        "slot_templates",
+        "responsible_contact",
+        new_column_name="responsible_override",
+        existing_type=sa.String(),
+        nullable=True,
+    )
+
+def downgrade():
+    op.alter_column(
+        "slot_templates",
+        "responsible_override",
+        new_column_name="responsible_contact",
+        existing_type=sa.String(),
+        nullable=False,
+    )
+    op.drop_constraint('fk_slot_templates_commission_id', 'slot_templates', type_='foreignkey')
+    op.drop_column('slot_templates', 'commission_id')

+ 149 - 0
alembic/versions/2026072155_implement_commission_and_organisation_285f2f9c840a.py

@@ -0,0 +1,149 @@
+"""implement commission and organisation
+
+Revision ID: 285f2f9c840a
+Revises: 4e48e5c634f0
+Create Date: 2026-07-21 00:55:59.819037
+
+"""
+from uuid import uuid4
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import table, column, String
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '285f2f9c840a'
+down_revision = '4e48e5c634f0'
+branch_labels = None
+depends_on = None
+
+
+
+
+def upgrade():
+    bind = op.get_bind()
+
+    # ---- explicitly create enum types before referencing them anywhere ----
+    org_role_enum = postgresql.ENUM(
+        "ORG_ADMIN", "RESPO_BENEVOLE", "RESPO_COMMISSION", name="org_role", create_type=False
+    )
+    org_role_enum.create(bind, checkfirst=True)
+
+    global_role_enum = postgresql.ENUM("SUPER_ADMIN", "USER", name="global_role", create_type=False)
+    global_role_enum.create(bind, checkfirst=True)
+    # ---- schema: new tables (unchanged from your version) ----
+    op.create_table('organizations',
+        sa.Column('id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('name', sa.String(length=128), nullable=False),
+        sa.PrimaryKeyConstraint('id'),
+    )
+    op.create_index(op.f('ix_organizations_name'), 'organizations', ['name'], unique=True)
+
+    op.create_table('user_organizations',
+        sa.Column('user_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('organization_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('role', org_role_enum, nullable=False),
+        sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
+        sa.ForeignKeyConstraint(['user_id'], ['user_model.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('user_id', 'organization_id'),
+    )
+
+    op.create_table('commissions',
+        sa.Column('id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('project_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('name', sa.String(length=128), nullable=False),
+        sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('id'),
+    )
+
+    op.create_table('volunteer_groups',
+        sa.Column('id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('project_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('name', sa.String(length=128), nullable=False),
+        sa.Column('color', sa.String(length=16), nullable=True),
+        sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('id'),
+    )
+
+    op.create_table('association_volunteer_group',
+        sa.Column('volunteer_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('group_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.ForeignKeyConstraint(['group_id'], ['volunteer_groups.id'], ondelete='CASCADE'),
+        sa.ForeignKeyConstraint(['volunteer_id'], ['volunteers.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('volunteer_id', 'group_id'),
+    )
+
+    op.create_table('commission_members',
+        sa.Column('commission_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.Column('user_id', sa.UUID(as_uuid=False), nullable=False),
+        sa.ForeignKeyConstraint(['commission_id'], ['commissions.id'], ondelete='CASCADE'),
+        sa.ForeignKeyConstraint(['user_id'], ['user_model.id'], ondelete='CASCADE'),
+        sa.PrimaryKeyConstraint('commission_id', 'user_id'),
+    )
+
+    # ---- schema: new columns, added NULLABLE first ----
+    op.add_column('projects', sa.Column('organization_id', sa.UUID(as_uuid=False), nullable=True))
+    op.add_column('user_model', sa.Column('name', sa.String(length=128), nullable=False, server_default=''))
+    op.add_column('user_model', sa.Column('phone_number', sa.String(length=24), nullable=True))
+    op.add_column(
+        "user_model",
+        sa.Column("global_role", global_role_enum, nullable=False, server_default="USER"),
+    )
+
+    ## ---- data migration ----
+    organizations_t = table('organizations', column('id', String), column('name', String))
+    user_organizations_t = table(
+        'user_organizations', column('user_id', String), column('organization_id', String), column('role', String)
+    )
+    projects_t = table('projects', column('id', String), column('organization_id', String))
+    users_t = table('user_model', column('id', String))
+
+    default_org_id = str(uuid4())
+    bind.execute(organizations_t.insert().values(id=default_org_id, name='Default'))
+    bind.execute(projects_t.update().values(organization_id=default_org_id))
+
+    existing_user_ids = [row[0] for row in bind.execute(sa.select(users_t.c.id))]
+    if existing_user_ids:
+        bind.execute(
+            user_organizations_t.insert(),
+            [
+                {'user_id': uid, 'organization_id': default_org_id, 'role': 'ORG_ADMIN'}
+                for uid in existing_user_ids
+            ],
+        )
+
+    # ---- schema: tighten ----
+    op.alter_column("projects", "organization_id", nullable=False)
+    op.create_foreign_key(
+        "fk_projects_organization_id",
+        "projects",
+        "organizations",
+        ["organization_id"],
+        ["id"],
+        ondelete="CASCADE",
+    )
+    op.alter_column("user_model", "name", server_default=None)
+    op.alter_column("user_model", "global_role", server_default=None)
+
+
+def downgrade():
+    bind = op.get_bind()
+
+    op.drop_column('user_model', 'global_role')
+    op.drop_column('user_model', 'phone_number')
+    op.drop_column('user_model', 'name')
+    op.drop_constraint('fk_projects_organization_id', 'projects', type_='foreignkey')
+    op.drop_column('projects', 'organization_id')
+    op.drop_table('commission_members')
+    op.drop_table('association_volunteer_group')
+    op.drop_table('volunteer_groups')
+    op.drop_table('commissions')
+    op.drop_table('user_organizations')
+    op.drop_index(op.f('ix_organizations_name'), table_name='organizations')
+    op.drop_table('organizations')
+
+
+    # drop enum types last, after every column referencing them is gone
+    postgresql.ENUM(name='global_role').drop(bind, checkfirst=True)
+    postgresql.ENUM(name='org_role').drop(bind, checkfirst=True)

+ 55 - 0
alembic/versions/2026072531_finalize_authorisation_f5fc28123cc0.py

@@ -0,0 +1,55 @@
+"""finalize_authorisation
+
+Revision ID: f5fc28123cc0
+Revises: f7a9944a660c
+Create Date: 2026-07-25 00:31:02.716514
+
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "f5fc28123cc0"
+down_revision = "f7a9944a660c"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "organizations",
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=False,
+        ),
+    )
+    op.add_column(
+        "organizations", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True)
+    )
+    op.add_column(
+        "volunteer_groups",
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=False,
+        ),
+    )
+    op.add_column(
+        "volunteer_groups", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True)
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("volunteer_groups", "updated_at")
+    op.drop_column("volunteer_groups", "created_at")
+    op.drop_column("organizations", "updated_at")
+    op.drop_column("organizations", "created_at")
+    # ### end Alembic commands ###

+ 36 - 0
alembic/versions/2026072549_add_timestamp_to_commission_b9072f955a8d.py

@@ -0,0 +1,36 @@
+"""add timestamp to commission
+
+Revision ID: b9072f955a8d
+Revises: f5fc28123cc0
+Create Date: 2026-07-25 09:49:34.418158
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = 'b9072f955a8d'
+down_revision = 'f5fc28123cc0'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('commissions', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
+    op.add_column('commissions', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False))
+    op.alter_column('volunteer_groups', 'updated_at',
+               existing_type=postgresql.TIMESTAMP(timezone=True),
+               nullable=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column('volunteer_groups', 'updated_at',
+               existing_type=postgresql.TIMESTAMP(timezone=True),
+               nullable=True)
+    op.drop_column('commissions', 'updated_at')
+    op.drop_column('commissions', 'created_at')
+    # ### end Alembic commands ###

+ 32 - 0
alembic/versions/2026072635_add_must_change_password_to_user_3b96bd12a533.py

@@ -0,0 +1,32 @@
+"""add must_change_password to user
+
+Revision ID: 3b96bd12a533
+Revises: b9072f955a8d
+Create Date: 2026-07-26 18:35:09.831937
+
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "3b96bd12a533"
+down_revision = "b9072f955a8d"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "user_model",
+        sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default="false"),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("user_model", "must_change_password")
+    # ### end Alembic commands ###

+ 51 - 0
alembic/versions/2026072652_add_password_reset_request_log_a2c893432a1f.py

@@ -0,0 +1,51 @@
+"""add password_reset_request_log
+
+Revision ID: a2c893432a1f
+Revises: 3b96bd12a533
+Create Date: 2026-07-26 19:52:07.317867
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a2c893432a1f'
+down_revision = '3b96bd12a533'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('password_reset_request_log',
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('email', sa.String(length=254), nullable=False),
+    sa.Column('requested_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_password_reset_request_log_email'), 'password_reset_request_log', ['email'], unique=False)
+    op.create_table('password_reset_tokens',
+    sa.Column('id', sa.UUID(as_uuid=False), nullable=False),
+    sa.Column('user_id', sa.UUID(as_uuid=False), nullable=False),
+    sa.Column('token_hash', sa.String(length=64), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+    sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+    sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
+    sa.Column('purpose', sa.String(length=32), nullable=False),
+    sa.ForeignKeyConstraint(['user_id'], ['user_model.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_password_reset_tokens_token_hash'), 'password_reset_tokens', ['token_hash'], unique=True)
+    op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens')
+    op.drop_index(op.f('ix_password_reset_tokens_token_hash'), table_name='password_reset_tokens')
+    op.drop_table('password_reset_tokens')
+    op.drop_index(op.f('ix_password_reset_request_log_email'), table_name='password_reset_request_log')
+    op.drop_table('password_reset_request_log')
+    # ### end Alembic commands ###

+ 52 - 0
alembic/versions/2026072729_persist_project_constraints_f647c6bd94ae.py

@@ -0,0 +1,52 @@
+"""persist project_constraints
+
+Revision ID: f647c6bd94ae
+Revises: a2c893432a1f
+Create Date: 2026-07-27 01:29:36.369652
+
+"""
+
+import json
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "f647c6bd94ae"
+down_revision = "a2c893432a1f"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    default_constraints = json.dumps(
+        {
+            "dejeuner_start": 11,
+            "dejeuner_end": 14,
+            "diner_start": 19,
+            "diner_end": 22,
+            "nuit_start": 22,
+            "nuit_end": 10,
+            "duree_repas_h": 1.0,
+            "duree_nuit_h": 8.0,
+            "duree_pause_h": 0.5,
+        }
+    )
+    op.add_column(
+        "projects",
+        sa.Column(
+            "constraints",
+            sa.JSON(),
+            nullable=False,
+            server_default=default_constraints,
+        ),
+    )
+    # drop the server_default after backfill so future inserts must go through
+    # the Pydantic model (PlanningConstraints) rather than silently relying on
+    # a DB-level default that can drift from the schema's defaults over time
+    op.alter_column("projects", "constraints", server_default=None)
+
+
+def downgrade():
+    op.drop_column("projects", "constraints")

+ 43 - 0
app/api/SSEBroadcasterRoute.py

@@ -0,0 +1,43 @@
+import asyncio
+import json
+
+from fastapi import Request, Response
+from fastapi.routing import APIRoute
+
+from app.core.events import broadcast
+
+
+class SSEBroadcasterRoute(APIRoute):
+    def get_route_handler(self):
+        original_route_handler = super().get_route_handler()
+
+        async def custom_route_handler(request: Request) -> Response:
+            # 1. Execute the route and let FastAPI serialize the response
+            response = await original_route_handler(request)
+
+            openapi_extra = self.openapi_extra or {}
+            event_name = openapi_extra.get("sse_event")
+
+            # 2. Check if an event is declared and the request was successful
+            if event_name and response.status_code in (200, 201):
+                project_id = request.path_params.get("project_id")
+                client_id = request.headers.get("X-Client-ID", "unknown")
+
+                if request.method == "DELETE":
+                    # For DELETE, the response body is often empty. We broadcast the path parameters.
+                    item_json = json.dumps(request.path_params)
+                else:
+                    # For POST/PUT, response.body is already serialized as a JSON string by FastAPI
+                    item_json = response.body.decode("utf-8")
+
+                # Assemble the raw payload string
+                payload_str = (
+                    f'{{"client_id": "{client_id}", "event": "{event_name}", "item": {item_json}}}'
+                )
+
+                channel = f"project_{project_id}"
+                asyncio.create_task(broadcast.publish(channel, payload_str))
+
+            return response
+
+        return custom_route_handler

+ 11 - 3
app/api/api.py

@@ -2,23 +2,31 @@ from fastapi import APIRouter
 
 from app.api.endpoints import (
     auth,
+    commissions,
+    organizations,
     project,
+    project_stream,
     slots,
-    users,
-    volunteers,
     sms,
+    sms_sender,
     tags,
     templates,
-    sms_sender,
+    users,
+    volunteer_groups,
+    volunteers,
 )
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
 api_router.include_router(users.router, prefix="/users", tags=["users"])
+api_router.include_router(organizations.router, prefix="/organizations", tags=["organization"])
 api_router.include_router(project.router, tags=["project"])
+api_router.include_router(project_stream.router, tags=["project"])
+api_router.include_router(commissions.router, tags=["commissions"])
 api_router.include_router(slots.router, tags=["slot"])
 api_router.include_router(tags.router, tags=["tag"])
 api_router.include_router(volunteers.router, tags=["volunteer"])
+api_router.include_router(volunteer_groups.router, tags=["volunteer_group"])
 api_router.include_router(sms.router, tags=["sms"])
 api_router.include_router(templates.router, tags=["template"])
 api_router.include_router(sms_sender.router, tags=["sms_sender"])

+ 127 - 14
app/api/deps.py

@@ -1,18 +1,29 @@
 import time
-from typing import Generator
+from collections.abc import Generator
+from uuid import UUID
 
 import jwt
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Path, status
 from fastapi.security import OAuth2PasswordBearer
-from sqlalchemy import select
+from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 
+from app.api.utils import get_project_organization_id
 from app.core import config, security
 from app.core.session import session
-from app.models import User
+from app.models import (
+    GlobalRole,
+    OrgRole,
+    Project,
+    User,
+    UserOrganization,
+    association_table_commission_member,
+)
 
 reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
 
+FULL_ACCESS_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
 
 def get_session() -> Generator[Session, None, None]:
     with session() as db:
@@ -25,28 +36,130 @@ async def get_current_user(
     try:
         payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
     except jwt.DecodeError:
-        raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials.",
-        )
-    # JWT guarantees payload will be unchanged (and thus valid), no errors here
+        raise HTTPException(status.HTTP_403_FORBIDDEN, "Could not validate credentials.")
+
     token_data = security.JWTTokenPayload(**payload)
 
     if token_data.refresh:
         raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials, cannot use refresh token",
+            status.HTTP_403_FORBIDDEN, "Could not validate credentials, cannot use refresh token"
         )
+
     now = int(time.time())
     if now < token_data.issued_at or now > token_data.expires_at:
         raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials, token expired or not yet valid",
+            status.HTTP_403_FORBIDDEN,
+            "Could not validate credentials, token expired or not yet valid",
         )
 
     result = session.execute(select(User).where(User.id == token_data.sub))
     user = result.scalars().first()
-
     if not user:
         raise HTTPException(status_code=404, detail="User not found.")
     return user
+
+
+def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
+    if current_user.global_role != GlobalRole.SUPER_ADMIN:
+        raise HTTPException(status.HTTP_403_FORBIDDEN, "Requires super_admin")
+    return current_user
+
+
+def _has_org_role(
+    session: Session, user_id: UUID, project_id: UUID, *allowed_roles: OrgRole
+) -> bool:
+    """Single query: does user_id hold one of allowed_roles in the
+    organization that owns project_id? Replaces loading the user's full
+    organizations list and scanning it in Python."""
+    return (
+        session.execute(
+            select(
+                exists().where(
+                    UserOrganization.user_id == user_id,
+                    UserOrganization.role.in_(allowed_roles),
+                    UserOrganization.organization_id
+                    == (
+                        select(Project.organization_id)
+                        .where(Project.id == project_id)
+                        .scalar_subquery()
+                    ),
+                )
+            )
+        ).scalar()
+        is True
+    )
+
+
+def _is_commission_member(session: Session, user_id: str, commission_id: str | None) -> bool:
+    if commission_id is None:
+        return False
+    stmt = select(
+        exists().where(
+            association_table_commission_member.c.commission_id == commission_id,
+            association_table_commission_member.c.user_id == user_id,
+        )
+    )
+    return session.scalar(stmt)
+
+
+def require_org_role(*allowed_roles: OrgRole):
+    def dependency(
+        project_id: UUID = Path(...),
+        session: Session = Depends(get_session),
+        current_user: User = Depends(get_current_user),
+    ) -> User:
+        if current_user.global_role == GlobalRole.SUPER_ADMIN:
+            return current_user
+        if not _has_org_role(session, current_user.id, project_id, *allowed_roles):
+            get_project_organization_id(session, project_id)  # raises 404 if project missing
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
+            )
+        return current_user
+
+    return dependency
+
+
+def assert_commission_ownership(
+    session: Session, current_user: User, project_id: UUID, commission_id: str | None
+) -> None:
+    """Called inside create/update handlers once the payload's commission_id
+    is known. org_admin/respo_benevole: any commission is fine. respo_commission:
+    must be a member of commission_id, which must be set."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        return
+    if _has_org_role(session, current_user.id, project_id, *FULL_ACCESS_ROLES):
+        return
+    if commission_id is None or not _is_commission_member(
+        session, current_user.id, str(commission_id)
+    ):
+        raise HTTPException(403, "Cannot assign to a commission you are not a member of")
+
+
+def require_organization_role(*allowed_roles: OrgRole):
+    """Authorize based on the caller's role within the organization named
+    directly in the path (organization_id), rather than resolved through a
+    project. Super admins always pass."""
+
+    def dependency(
+        organization_id: UUID = Path(...),
+        current_user: User = Depends(get_current_user),
+    ) -> User:
+        if current_user.global_role == GlobalRole.SUPER_ADMIN:
+            return current_user
+
+        role = next(
+            (
+                m.role
+                for m in current_user.organizations
+                if m.organization_id == str(organization_id)
+            ),
+            None,
+        )
+        if role is None or role not in allowed_roles:
+            raise HTTPException(
+                status.HTTP_403_FORBIDDEN, "Insufficient permissions for this organization"
+            )
+        return current_user
+
+    return dependency

+ 229 - 0
app/api/endpoints/commissions.py

@@ -0,0 +1,229 @@
+import secrets
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload
+
+from app.api import deps
+from app.api.utils import assert_project_exists_or_404, issue_reset_token
+from app.core.config import settings
+from app.core.email import EmailSender, get_email_sender
+from app.core.security import get_password_hash
+from app.models import Commission, OrgRole, Project, User, UserOrganization
+from app.schemas.requests import (
+    CommissionCreateRequest,
+    CommissionMembershipRequest,
+    CommissionUpdateRequest,
+    UserInviteRequest,
+)
+from app.schemas.responses import CommissionResponse
+
+router = APIRouter(prefix="/project/{project_id}", tags=["commissions"])
+
+READ_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+MANAGE_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
+
+def _get_commission_or_404(session: Session, project_id: UUID, commission_id: UUID) -> Commission:
+    commission = session.scalar(
+        select(Commission)
+        .where(Commission.id == commission_id)
+        .options(selectinload(Commission.members))
+    )
+    if commission is None or str(commission.project_id) != str(project_id):
+        raise HTTPException(status_code=404, detail="Commission not found")
+    return commission
+
+
+@router.get("/commissions", response_model=list[CommissionResponse])
+async def list_project_commissions(
+    project_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """List all commissions for a specific project"""
+    assert_project_exists_or_404(session, project_id)
+    results = session.execute(select(Commission).where(Commission.project_id == project_id))
+    return results.scalars().all()
+
+
+@router.post(
+    "/commission",
+    response_model=CommissionResponse,
+    openapi_extra={"sse_event": "commission_created"},
+)
+async def create_commission(
+    project_id: UUID,
+    new_commission: CommissionCreateRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Create a new commission in the project"""
+    assert_project_exists_or_404(session, project_id)
+    commission = Commission(project_id=project_id, **new_commission.model_dump())
+    session.add(commission)
+    session.commit()
+    session.refresh(commission)
+    return commission
+
+
+@router.get("/commission/{commission_id}", response_model=CommissionResponse)
+async def get_commission(
+    project_id: UUID,
+    commission_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*READ_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Get a single commission"""
+    return _get_commission_or_404(session, project_id, commission_id)
+
+
+@router.post(
+    "/commission/{commission_id}",
+    response_model=CommissionResponse,
+    openapi_extra={"sse_event": "commission_updated"},
+)
+async def update_commission(
+    project_id: UUID,
+    commission_id: UUID,
+    edit_commission: CommissionUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Edit a commission's details"""
+    commission = _get_commission_or_404(session, project_id, commission_id)
+    commission.name = edit_commission.name
+    session.commit()
+    session.refresh(commission)
+    return commission
+
+
+@router.delete(
+    "/commission/{commission_id}",
+    openapi_extra={"sse_event": "commission_deleted"},
+)
+async def delete_commission(
+    project_id: UUID,
+    commission_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Delete a commission (automatically deletes CommissionMember links via cascade)"""
+    commission = _get_commission_or_404(session, project_id, commission_id)
+    session.delete(commission)
+    session.commit()
+
+
+@router.post(
+    "/commission/{commission_id}/members",
+    response_model=CommissionResponse,
+    openapi_extra={"sse_event": "commission_updated"},
+)
+async def add_members_to_commission(
+    project_id: UUID,
+    commission_id: UUID,
+    payload: CommissionMembershipRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Bulk-add users as members to a commission (idempotent)"""
+    commission = _get_commission_or_404(session, project_id, commission_id)
+
+    # Verify users exist
+    valid_users = session.scalars(select(User).where(User.id.in_(payload.user_ids))).all()
+    if len(valid_users) != len(payload.user_ids):
+        raise HTTPException(status_code=400, detail="One or more invalid user IDs")
+
+    existing_ids = {u.id for u in commission.members}
+    for user in valid_users:
+        if user.id not in existing_ids:
+            commission.members.append(user)
+    session.commit()
+
+    return _get_commission_or_404(session, project_id, commission_id)
+
+
+@router.delete(
+    "/commission/{commission_id}/member/{user_id}",
+    response_model=CommissionResponse,
+    openapi_extra={"sse_event": "commission_updated"},
+)
+async def remove_member_from_commission(
+    project_id: UUID,
+    commission_id: UUID,
+    user_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Remove a single user from a commission"""
+    commission = _get_commission_or_404(session, project_id, commission_id)
+
+    member_to_remove = next((m for m in commission.members if str(m.id) == str(user_id)), None)
+
+    if member_to_remove:
+        session.delete(member_to_remove)
+        session.commit()
+
+    return _get_commission_or_404(session, project_id, commission_id)
+
+
+@router.post(
+    "/commission/{commission_id}/invite-member",
+    response_model=CommissionResponse,
+    openapi_extra={"sse_event": "commission_updated"},
+)
+async def invite_commission_member(
+    project_id: UUID,
+    commission_id: UUID,
+    payload: UserInviteRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+    email_sender: EmailSender = Depends(get_email_sender),
+):
+    commission = _get_commission_or_404(session, project_id, commission_id)
+    project = session.get(Project, project_id)
+
+    user = session.scalar(select(User).where(User.email == payload.email))
+    is_new_user = user is None
+
+    if payload.org_role == OrgRole.ORG_ADMIN:
+        caller_membership = session.get(
+            UserOrganization, (current_user.id, project.organization_id)
+        )
+        is_caller_org_admin = (
+            caller_membership is not None and caller_membership.role == OrgRole.ORG_ADMIN
+        )
+        if not (is_caller_org_admin or current_user.global_role == "super_admin"):
+            raise HTTPException(status_code=403, detail="Only an org_admin can grant org_admin")
+
+    if is_new_user:
+        user = User(
+            email=payload.email,
+            name=payload.name,
+            phone_number=payload.phone_number,
+            hashed_password=get_password_hash(secrets.token_urlsafe(32)),  # unusable placeholder
+            must_change_password=True,
+        )
+        session.add(user)
+        session.flush()
+
+    existing_membership = session.get(UserOrganization, (user.id, project.organization_id))
+    if existing_membership is None:
+        session.add(
+            UserOrganization(
+                user_id=user.id,
+                organization_id=project.organization_id,
+                role=payload.org_role,
+            )
+        )
+
+    if user not in commission.members:
+        commission.members.append(user)
+
+    session.commit()
+
+    if is_new_user:
+        await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL)
+
+    return _get_commission_or_404(session, project_id, commission_id)

+ 182 - 0
app/api/endpoints/organizations.py

@@ -0,0 +1,182 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session, selectinload
+
+from app.api import deps
+from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
+from app.schemas.requests import (
+    OrganizationMembershipCreateRequest,
+    OrganizationMembershipUpdateRequest,
+    OrganizationRequest,
+)
+from app.schemas.responses import (
+    OrganizationDetailResponse,
+    OrganizationMembershipResponse,
+    OrganizationResponse,
+)
+
+router = APIRouter()
+
+
+@router.get("/", response_model=list[OrganizationResponse])
+async def list_organizations(
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """List organizations. super_admin sees all; others see only orgs they belong to."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        results = session.execute(select(Organization))
+    else:
+        org_ids = [m.organization_id for m in current_user.organizations]
+        if not org_ids:
+            return []
+        results = session.execute(select(Organization).where(Organization.id.in_(org_ids)))
+    return results.scalars().all()
+
+
+@router.post("/", response_model=OrganizationResponse)
+async def create_organization(
+    new_org: OrganizationRequest,
+    current_user: User = Depends(deps.require_super_admin),
+    session: Session = Depends(deps.get_session),
+):
+    """Create a new organization. super_admin only."""
+    org = Organization(**new_org.model_dump())
+    session.add(org)
+    try:
+        session.commit()
+    except IntegrityError:
+        raise HTTPException(400, "Organization name already exists")
+    session.refresh(org)
+    return org
+
+
+@router.get("/{organization_id}", response_model=OrganizationDetailResponse)
+async def get_organization(
+    organization_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Get organization detail including members.
+    super_admin, or any member of the organization, may view it."""
+    org = (
+        session.execute(
+            select(Organization)
+            .where(Organization.id == str(organization_id))
+            .options(selectinload(Organization.memberships).selectinload(UserOrganization.user))
+        )
+        .scalars()
+        .first()
+    )
+    if org is None:
+        raise HTTPException(404, "Organization not found")
+
+    if current_user.global_role != GlobalRole.SUPER_ADMIN:
+        is_member = any(m.organization_id == org.id for m in current_user.organizations)
+        if not is_member:
+            raise HTTPException(403, "Not a member of this organization")
+    return org
+
+
+@router.post("/{organization_id}", response_model=OrganizationResponse)
+async def update_organization(
+    organization_id: UUID,
+    edit_org: OrganizationRequest,
+    current_user: User = Depends(deps.require_super_admin),
+    session: Session = Depends(deps.get_session),
+):
+    """Rename an organization. super_admin only."""
+    org = session.get(Organization, str(organization_id))
+    if org is None:
+        raise HTTPException(404, "Organization not found")
+    org.name = edit_org.name
+    try:
+        session.commit()
+    except IntegrityError:
+        raise HTTPException(400, "Organization name already exists")
+    session.refresh(org)
+    return org
+
+
+@router.delete("/{organization_id}")
+async def delete_organization(
+    organization_id: UUID,
+    current_user: User = Depends(deps.require_super_admin),
+    session: Session = Depends(deps.get_session),
+):
+    """Delete an organization. Cascades to every project (and everything under
+    those projects) in the org — this is intentionally destructive, confirm
+    client-side before calling this."""
+    org = session.get(Organization, str(organization_id))
+    if org is None:
+        raise HTTPException(404, "Organization not found")
+    session.delete(org)
+    session.commit()
+
+
+@router.post("/{organization_id}/members", response_model=OrganizationMembershipResponse)
+async def add_member(
+    organization_id: UUID,
+    payload: OrganizationMembershipCreateRequest,
+    current_user: User = Depends(deps.require_super_admin),
+    session: Session = Depends(deps.get_session),
+):
+    """Add an existing user to an organization with a role. super_admin only.
+    Does NOT create user accounts — the user must already have signed up.
+    (User self-registration / invite-by-email is a separate concern for the
+    users.py router; this endpoint only manages org membership.)"""
+    org = session.get(Organization, organization_id)
+    if org is None:
+        raise HTTPException(404, "Organization not found")
+
+    user = session.get(User, payload.user_id)
+    if user is None:
+        raise HTTPException(404, "User not found")
+
+    existing = session.get(UserOrganization, (payload.user_id, organization_id))
+    if existing is not None:
+        raise HTTPException(400, "User is already a member of this organization")
+
+    membership = UserOrganization(
+        user_id=payload.user_id, organization_id=organization_id, role=payload.role
+    )
+    session.add(membership)
+    session.commit()
+    session.refresh(membership)
+    return membership
+
+
+@router.post("/{organization_id}/members/{user_id}", response_model=OrganizationMembershipResponse)
+async def update_member_role(
+    organization_id: UUID,
+    user_id: UUID,
+    payload: OrganizationMembershipUpdateRequest,
+    current_user: User = Depends(deps.require_organization_role(OrgRole.ORG_ADMIN)),
+    session: Session = Depends(deps.get_session),
+):
+    """Change a member's role within the organization. super_admin only."""
+    membership = session.get(UserOrganization, (str(user_id), str(organization_id)))
+    if membership is None:
+        raise HTTPException(404, "Membership not found")
+    membership.role = payload.role
+    session.commit()
+    session.refresh(membership)
+    return membership
+
+
+@router.delete("/{organization_id}/members/{user_id}")
+async def remove_member(
+    organization_id: UUID,
+    user_id: UUID,
+    current_user: User = Depends(deps.require_super_admin),
+    session: Session = Depends(deps.get_session),
+):
+    """Remove a user from an organization. super_admin only."""
+    membership = session.get(UserOrganization, (str(user_id), str(organization_id)))
+    if membership is None:
+        raise HTTPException(404, "Membership not found")
+    session.delete(membership)
+    session.commit()

+ 93 - 52
app/api/endpoints/project.py

@@ -1,28 +1,36 @@
-from datetime import timedelta, datetime, timezone
+from datetime import UTC, datetime, timedelta
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
+from app.api.utils import assert_project_exists_or_404, get_project_or_404
+from app.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
 from app.models import (
+    Commission,
+    GlobalRole,
+    OrgRole,
     Project,
     Slot,
-    SlotTemplate,
     SlotTag,
+    SlotTemplate,
     Sms,
     User,
     Volunteer,
+    VolunteerGroup,
 )
+from app.schemas.objects import PlanningConstraints
 from app.schemas.requests import (
+    ProjectConstraintUpdateRequest,
+    ProjectCreateRequest,
     ProjectImportGsheetRequest,
-    ProjectRequest,
     ProjectSMSBatchRequest,
+    ProjectUpdateRequest,
 )
 from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
-from app.importData.gsheet import parseGsheet, extract_doc_uid, ParsingError
 
 router = APIRouter()
 
@@ -32,8 +40,14 @@ async def list_project(
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Get project_list"""
-    results = session.execute(select(Project))
+    """Get project list, scoped to the caller's organizations (all projects for super_admin)."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        results = session.execute(select(Project))
+    else:
+        org_ids = [m.organization_id for m in current_user.organizations]
+        if not org_ids:
+            return []
+        results = session.execute(select(Project).where(Project.organization_id.in_(org_ids)))
     return results.scalars().all()
 
 
@@ -41,18 +55,27 @@ async def list_project(
 async def list_public_project(
     session: Session = Depends(deps.get_session),
 ):
-    """Get the list of public projects"""
+    """Get the list of public projects — unchanged, stays fully public."""
     results = session.execute(select(Project).where(Project.is_public))
     return results.scalars().all()
 
 
 @router.post("/project", response_model=ProjectResponse)
 async def create_project(
-    new_project: ProjectRequest,
+    new_project: ProjectCreateRequest,
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Create a new project"""
+    """Create a new project within one of the caller's organizations."""
+    org_id = str(new_project.organization_id)
+    if current_user.global_role != GlobalRole.SUPER_ADMIN:
+        role = next(
+            (m.role for m in current_user.organizations if m.organization_id == org_id),
+            None,
+        )
+        if role != OrgRole.ORG_ADMIN:
+            raise HTTPException(403, "Only an org_admin can create projects for this organization")
+
     project = Project(**new_project.model_dump())
     session.add(project)
     try:
@@ -61,7 +84,6 @@ async def create_project(
         raise HTTPException(400, "Project name already exist")
 
     session.refresh(project)
-
     return project
 
 
@@ -70,7 +92,7 @@ async def get_public_project(
     project_id: UUID,
     session: Session = Depends(deps.get_session),
 ):
-    """Get a project that is public"""
+    """Get a project that is public — unchanged, stays fully public."""
     result = session.get(Project, project_id)
     if (result is None) or not result.is_public:
         raise HTTPException(status_code=404, detail="Project not found")
@@ -80,38 +102,77 @@ async def get_public_project(
 @router.get("/project/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(
+        deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+    ),
     session: Session = Depends(deps.get_session),
 ):
     """Get a project"""
-    project = session.get(Project, project_id)
+    project = session.scalar(
+        select(Project)
+        .where(Project.id == project_id)
+        .options(
+            selectinload(Project.volunteers),
+            selectinload(Project.sms),
+            selectinload(Project.slots).selectinload(Slot.volunteers),
+            selectinload(Project.slots).selectinload(Slot.template),
+            selectinload(Project.tags).selectinload(SlotTag.templates),
+            selectinload(Project.templates).selectinload(SlotTemplate.tags),
+            selectinload(Project.templates).selectinload(SlotTemplate.commission),
+            selectinload(Project.groups).selectinload(VolunteerGroup.volunteers),
+            selectinload(Project.commissions).selectinload(Commission.templates),
+            selectinload(Project.commissions).selectinload(Commission.members),
+        )
+    )
     if project is None:
         raise HTTPException(status_code=404, detail="Project not found")
     return project
 
 
-@router.post("/project/{project_id}", response_model=ProjectListResponse)
+@router.post(
+    "/project/{project_id}",
+    response_model=ProjectListResponse,
+    openapi_extra={"sse_event": "project_updated"},
+)
 async def update_project(
     project_id: UUID,
-    edit_project: ProjectRequest,
-    current_user: User = Depends(deps.get_current_user),
+    edit_project: ProjectUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
     session: Session = Depends(deps.get_session),
 ):
     """Edit project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    p = get_project_or_404(session, project_id)
     p.name = edit_project.name
     p.is_public = edit_project.is_public
     session.commit()
     return p
 
 
+@router.post(
+    "/project/{project_id}/constraints",
+    openapi_extra={"sse_event": "project_constraints_updated"},
+    response_model=PlanningConstraints,
+)
+async def update_project_constraints(
+    project_id: UUID,
+    payload: ProjectConstraintUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
+    session: Session = Depends(deps.get_session),
+):
+    project = session.get(Project, project_id)
+    if project is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    project.constraints = payload.model_dump()
+    session.commit()
+    session.refresh(project)
+    return project.constraints
+
+
 @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
 async def update_project_from_gsheet(
     project_id: UUID,
     gsheet: ProjectImportGsheetRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
     session: Session = Depends(deps.get_session),
 ):
     """Edit project name"""
@@ -125,13 +186,11 @@ async def update_project_from_gsheet(
         p.volunteers = []
         p.tags = []
         p.templates = []
-    # Parse the gsheets
     try:
         data = parseGsheet(doc_id, gsheet.satursday_date)
     except ParsingError as exc:
         raise HTTPException(status_code=422, detail=str(exc))
 
-    # Create the volunteer list
     volunteer_map: dict[str, Volunteer] = {}
     for _, row in data.contact.iterrows():
         volunteer = Volunteer(
@@ -145,17 +204,20 @@ async def update_project_from_gsheet(
         volunteer_map[row.key] = volunteer
         session.add(volunteer)
 
-    # Create creneau templates
     template_map = {}
     tags_map = {tag.title: tag for tag in p.tags}
 
     for _, row in data.creneauData.iterrows():
+        # NOTE: responsible_override stays free-text on gsheet import for now —
+        # linking to a real Commission requires matching against commission
+        # names/members, which the sheet doesn't carry. Revisit once the
+        # gsheet format itself is updated to reference commissions.
         template = SlotTemplate(
             project_id=project_id,
             title=row.title,
             description=row.description,
             place=row.lieu,
-            responsible_contact=row.responsable,
+            responsible_override=row.responsable,
         )
 
         for s_tag in row.tags.split(","):
@@ -172,7 +234,6 @@ async def update_project_from_gsheet(
         session.add(template)
 
     df_planning = data.planning
-    # group planning entry per same name and timing
     date_format = "%Y/%m/%d %H:%M"
     df_planning["key"] = (
         df_planning.nom.str.strip()
@@ -183,7 +244,6 @@ async def update_project_from_gsheet(
     )
     df_slots = df_planning.groupby("key")
 
-    # Create slots
     for key in df_slots.groups.keys():
         group = df_slots.get_group(key)
         volunteers = group.benevole_nom.tolist()
@@ -194,12 +254,10 @@ async def update_project_from_gsheet(
             ending_time=group.end.iloc[0],
             required_volunteers=len(volunteers),
         )
-        # Add volunteer to slots
         for benevole_key in volunteers:
             if benevole_key in volunteer_map:
                 slot.volunteers.append(volunteer_map[benevole_key])
 
-        # add detail information if available
         template_id = group.template_id.iloc[0]
         if template_id in template_map:
             slot.template = template_map[template_id]
@@ -214,30 +272,15 @@ async def update_project_from_gsheet(
 async def create_sms_batch(
     project_id: UUID,
     sms_batch: ProjectSMSBatchRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
     session: Session = Depends(deps.get_session),
 ):
-    """Create SMS based on a template and the list of slots and volunteer associated to the project
-
-    The placeholder that can be used in the template are
-     - {titre} slot.title
-     - {description} slot.description
-     - {debut} slot.starting_time
-     - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
-     - {prenom} volunteer.name
-     - {nom} volunteer.surname
-
-    """
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-    # Get all slots
+    """(docstring unchanged)"""
+    assert_project_exists_or_404(session, project_id)
     slots = session.execute(select(Slot).where(Slot.project_id == project_id))
     sms_list = []
-    now = datetime.now(timezone.utc)
+    now = datetime.now(UTC)
     for slot in slots.scalars():
-        # Replace the slot placeholder by their value
         slot_content = (
             sms_batch.template.replace("{titre}", slot.title)
             .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
@@ -245,17 +288,15 @@ async def create_sms_batch(
         )
         if slot.template is not None:
             slot_content = slot_content.replace("{description}", slot.template.description).replace(
-                "{respo}", slot.template.responsible_contact
+                "{respo}", slot.template.effective_responsible_contact
             )
 
         sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
-        # Skip SMS that should have been send before now
         if sending_time < now:
             continue
         for volunteer in slot.volunteers:
             if not volunteer.automatic_sms:
                 continue
-            # Create a new SMS customized for each user attache to the slot
             personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
                 "{nom}", volunteer.surname
             )
@@ -276,7 +317,7 @@ async def create_sms_batch(
 @router.delete("/project/{project_id}")
 async def delete_project(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete project"""

+ 49 - 0
app/api/endpoints/project_stream.py

@@ -0,0 +1,49 @@
+import json
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import StreamingResponse
+
+from app.api import deps
+from app.core.events import broadcast
+from app.models import OrgRole, User
+
+router = APIRouter()
+
+
+@router.get("/project/{project_id}/stream", summary="Real-Time SSE Stream")
+async def project_stream(
+    project_id: str,
+    client_id: str,
+    request: Request,
+    current_user: User = Depends(
+        deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+    ),
+):
+    """
+    Connect to this endpoint to receive real-time updates.
+
+    **Required Query Parameters:**
+    - `client_id`: A unique UUID generated by the frontend to prevent echoing.
+    """
+
+    async def event_generator():
+        channel = f"project_{project_id}"
+
+        # Subscribe to the Redis channel specific to this project
+        async with broadcast.subscribe(channel=channel) as subscriber:
+            async for event in subscriber:
+                # If the user closes the tab or navigates away, cleanly close the connection
+                if await request.is_disconnected():
+                    break
+                # Parse the raw string payload from Redis
+                message = json.loads(event.message)
+
+                # Echo prevention: Do not send the event back to the client that caused it
+                if message.get("client_id") == client_id:
+                    continue
+
+                # Yield the properly formatted Server-Sent Event (SSE)
+                yield f"event: {message['event']}\ndata: {json.dumps(message['item'])}\n\n"
+
+    # Return as text/event-stream so the browser knows it's an ongoing connection
+    return StreamingResponse(event_generator(), media_type="text/event-stream")

+ 49 - 27
app/api/endpoints/slots.py

@@ -5,9 +5,10 @@ from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload, verify_id_list
+from app.api.SSEBroadcasterRoute import SSEBroadcasterRoute
+from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.models import (
-    Project,
+    OrgRole,
     Slot,
     SlotTemplate,
     User,
@@ -20,35 +21,50 @@ from app.schemas.requests import (
 )
 from app.schemas.responses import SlotResponse
 
-router = APIRouter(prefix="/project/{project_id}", tags=["project"])
+router = APIRouter(
+    route_class=SSEBroadcasterRoute, prefix="/project/{project_id}", tags=["project"]
+)
+
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
+
+def assert_template_from_commission(
+    session: Session, current_user: User, project_id: UUID, template_id: UUID
+):
+    template_row = session.execute(
+        select(SlotTemplate.commission_id, SlotTemplate.project_id).where(
+            SlotTemplate.id == template_id
+        )
+    ).first()
+    if template_row is None or template_row[1] != str(project_id):
+        raise HTTPException(400, "Invalid template id")
+    deps.assert_commission_ownership(session, current_user, project_id, template_row[0])
 
 
 @router.get("/slots", response_model=list[SlotResponse])
 async def list_project_slots(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
+    assert_project_exists_or_404(session, project_id)
     results = session.execute(select(Slot).where(Slot.project_id == project_id))
     return results.scalars().all()
 
 
-@router.post("/slot", response_model=SlotResponse)
+@router.post("/slot", response_model=SlotResponse, openapi_extra={"sse_event": "slot_created"})
 async def create_slot(
     project_id: UUID,
     new_slot: SlotCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new slot to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists_or_404(session, project_id)
+    if new_slot.template_id:
+        assert_template_from_commission(session, current_user, project_id, new_slot.template_id)
 
     input_dict = new_slot.model_dump()
     # Extract volunteer list from input dict
@@ -72,12 +88,14 @@ async def create_slot(
     return slot
 
 
-@router.post("/slot/{slot_id}", response_model=SlotResponse)
+@router.post(
+    "/slot/{slot_id}", response_model=SlotResponse, openapi_extra={"sse_event": "slot_updated"}
+)
 async def update_slot(
     project_id: UUID,
     slot_id: UUID,
     new_slot: SlotUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a slot from the project"""
@@ -85,15 +103,10 @@ async def update_slot(
     if (slot is None) or (slot.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Slot not found : ")
 
-    if new_slot.template_id is not None:
-        if new_slot.template_id != "":
-            await verify_id_list(
-                session,
-                [new_slot.template_id],
-                project_id,
-                SlotTemplate,
-                "Invalid template id",
-            )
+    if new_slot.template_id:
+        assert_template_from_commission(session, current_user, project_id, new_slot.template_id)
+    if slot.template_id is not None:
+        assert_template_from_commission(session, current_user, project_id, slot.template_id)
 
     input_dict = new_slot.model_dump(exclude_unset=True)
     if "volunteers" in input_dict:
@@ -124,13 +137,22 @@ async def update_slot(
     return slot
 
 
-@router.delete("/slot/{slot_id}")
+@router.delete("/slot/{slot_id}", openapi_extra={"sse_event": "slot_deleted"})
 async def delete_slot(
     project_id: UUID,
     slot_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""
-    session.execute(delete(Slot).where((Slot.id == slot_id) & (Slot.project_id == project_id)))
+    result = session.execute(
+        select(Slot.id, Slot.template_id).where(Slot.id == slot_id, Slot.project_id == project_id)
+    ).first()
+    if result is None:
+        raise HTTPException(status_code=404, detail="Slot not found")
+
+    if result[1] is not None:
+        assert_template_from_commission(session, current_user, project_id, result[1])
+
+    session.execute(delete(Slot).where(Slot.id == slot_id))
     session.commit()

+ 18 - 13
app/api/endpoints/sms.py

@@ -1,13 +1,15 @@
+from typing import Annotated
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.params import Query
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload
+from app.api.utils import assert_project_exists_or_404, update_object_from_payload
 from app.models import (
-    Project,
+    OrgRole,
     Sms,
     User,
 )
@@ -16,19 +18,24 @@ from app.schemas.responses import SMSResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "sms"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
 
 @router.get("/sms", response_model=list[SMSResponse])
 async def list_project_sms(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
+    skip: Annotated[int, Query(ge=0)] = 0,
+    limit: Annotated[int, Query(ge=1, le=20000)] = 20000,
 ):
     """List sms from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists_or_404(session, project_id)
+    stmt = select(Sms).where(Sms.project_id == project_id)
+    stmt = stmt.order_by(Sms.created_at.desc()).offset(skip).limit(limit)
 
-    results = session.execute(select(Sms).where(Sms.project_id == project_id))
+    results = session.execute(stmt)
     return results.scalars().all()
 
 
@@ -36,13 +43,11 @@ async def list_project_sms(
 async def create_sms(
     project_id: UUID,
     new_sms: SmsCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists_or_404(session, project_id)
     sms = Sms(project_id=project_id, **new_sms.model_dump())
     session.add(sms)
     session.commit()
@@ -54,7 +59,7 @@ async def update_sms(
     project_id: UUID,
     sms_id: UUID,
     new_sms: SmsUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update an sms from the project"""
@@ -71,7 +76,7 @@ async def update_sms(
 async def delete_sms(
     project_id: UUID,
     sms_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a sms from the project"""

+ 13 - 14
app/api/endpoints/sms_sender.py

@@ -2,21 +2,21 @@ import datetime
 from typing import Annotated
 from uuid import UUID
 
-from fastapi import APIRouter, Depends, Query, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
-from app.core.config import settings
-from app.models import User, Sms, ServerStatus
-from app.schemas.responses import SMSResponse, SMSServerStatus, EnumServerStatus
 from app.api import deps
+from app.core.config import settings
+from app.models import ServerStatus, Sms, User
+from app.schemas.responses import EnumServerStatus, SMSResponse, SMSServerStatus
 
 router = APIRouter(prefix="/sms-sender", tags=["sms_sender"])
 
 
 @router.get("/sms/to-send", response_model=list[SMSResponse])
 async def list_sms_to_send(
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
     max_delay: Annotated[
         int | None, Query(description="the maximum delay a sms should be send with")
@@ -30,7 +30,7 @@ async def list_sms_to_send(
         select(Sms).where(
             (Sms.sending_time > min_sending_time)
             & (Sms.sending_time < now)
-            & (Sms.send_time == None)  # noqa: E711
+            & (Sms.send_time == None)
         )
     )
     return results.scalars().all()
@@ -40,7 +40,7 @@ async def list_sms_to_send(
 async def send_sms_now(
     request: Request,
     sms_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
 ):
     """Update the SMS to be sent now"""
@@ -58,17 +58,17 @@ async def send_sms_now(
 
 @router.get("/sms/not-send", response_model=list[SMSResponse])
 async def list_not_sent(
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
 ):
     """List sms that are not sent"""
-    results = session.execute(select(Sms).where((Sms.send_time == None)))  # noqa: E711
+    results = session.execute(select(Sms).where(Sms.send_time == None))
     return results.scalars().all()
 
 
 @router.get("/sms/future", response_model=list[SMSResponse])
 async def list_future_sms(
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
 ):
     """List sms that should be sent in the future"""
@@ -79,7 +79,7 @@ async def list_future_sms(
 @router.post("/status")
 async def update_status(
     request: Request,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
 ):
     """Update the status of the SMS server"""
@@ -89,7 +89,7 @@ async def update_status(
         status = ServerStatus(id=1)
 
     status.host = request.client.host
-    status.updated_at = datetime.datetime.now(datetime.timezone.utc)
+    status.updated_at = datetime.datetime.now(datetime.UTC)
     status.user_agent = request.headers.get("user-agent", "unknown")
     session.add(status)
     session.commit()
@@ -107,13 +107,12 @@ async def get_status(
 ):
     """Get the latest status of the SMS server"""
     status: ServerStatus | None = session.query(ServerStatus).filter_by(id=1).first()
-    now = datetime.datetime.now(datetime.timezone.utc)
+    now = datetime.datetime.now(datetime.UTC)
     if not status:
         return SMSServerStatus(
             updated_at=now, host="N/A", user_agent="N/A", message=EnumServerStatus.INVALID
         )
     else:
-
         msg = (
             EnumServerStatus.INACTIVE
             if (now - status.updated_at).total_seconds()

+ 16 - 12
app/api/endpoints/tags.py

@@ -5,8 +5,9 @@ from sqlalchemy import delete
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import verify_id_list
+from app.api.utils import get_project_or_404, verify_id_list
 from app.models import (
+    OrgRole,
     Project,
     SlotTag,
     SlotTemplate,
@@ -18,25 +19,26 @@ from app.schemas.responses import SlotResponse, TagResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "tag"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
 
 @router.get("/tags", response_model=list[TagResponse])
 async def list_project_tags(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    p = get_project_or_404(session, project_id)
     return p.tags
 
 
-@router.post("/tag", response_model=TagResponse)
+@router.post("/tag", response_model=TagResponse, openapi_extra={"sse_event": "tag_created"})
 async def create_tag(
     project_id: UUID,
     payload: TagCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new tag to the project"""
@@ -65,12 +67,14 @@ async def create_tag(
     return tag
 
 
-@router.post("/tag/{tag_id}", response_model=TagResponse)
+@router.post(
+    "/tag/{tag_id}", response_model=TagResponse, openapi_extra={"sse_event": "tag_updated"}
+)
 async def update_tag(
     project_id: UUID,
     tag_id: UUID,
     payload: TagUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a tag"""
@@ -110,7 +114,7 @@ async def update_tag(
 async def list_tagged_slot(
     project_id: UUID,
     tag_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """list all slot associated with a given tag"""
@@ -124,11 +128,11 @@ async def list_tagged_slot(
     return list(slot_set)
 
 
-@router.delete("/tag/{tag_id}")
+@router.delete("/tag/{tag_id}", openapi_extra={"sse_event": "tag_deleted"})
 async def delete_tag(
     project_id: UUID,
     tag_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""

+ 40 - 16
app/api/endpoints/templates.py

@@ -1,12 +1,13 @@
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import delete
+from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload, verify_id_list
+from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.models import (
+    OrgRole,
     Project,
     SlotTag,
     SlotTemplate,
@@ -16,13 +17,17 @@ from app.models import (
 from app.schemas.requests import TemplateCreateRequest, TemplateUpdateRequest
 from app.schemas.responses import TemplateResponse
 
-router = APIRouter()
+router = APIRouter(
+    prefix="/project/{project_id}",
+)
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
 
 
-@router.get("/project/{project_id}/templates", response_model=list[TemplateResponse])
+@router.get("/templates", response_model=list[TemplateResponse])
 async def list_project_templates(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
@@ -32,19 +37,20 @@ async def list_project_templates(
     return p.templates
 
 
-@router.post("/project/{project_id}/template", response_model=TemplateResponse)
+@router.post(
+    "/template", response_model=TemplateResponse, openapi_extra={"sse_event": "template_created"}
+)
 async def create_template(
     project_id: UUID,
     payload: TemplateCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new template to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
-    template = SlotTemplate(project_id=p.id, title=payload.title)
+    assert_project_exists_or_404(session, project_id)
+    if payload.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, payload.commission_id)
+    template = SlotTemplate(project_id=project_id, title=payload.title)
 
     session.add(template)
     session.commit()
@@ -66,18 +72,26 @@ async def create_template(
     return template
 
 
-@router.post("/project/{project_id}/template/{template_id}", response_model=TemplateResponse)
+@router.post(
+    "/template/{template_id}",
+    response_model=TemplateResponse,
+    openapi_extra={"sse_event": "template_updated"},
+)
 async def update_template(
     project_id: UUID,
     template_id: UUID,
     payload: TemplateUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a template"""
     template = session.get(SlotTemplate, template_id)
     if (template is None) or (template.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Template not found")
+    if template.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, template.commission_id)
+    if payload.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, payload.commission_id)
 
     input_dict = payload.model_dump(exclude_unset=True)
     if payload.tags is not None:
@@ -105,14 +119,24 @@ async def update_template(
     return template
 
 
-@router.delete("/project/{project_id}/template/{template_id}")
+@router.delete("/template/{template_id}", openapi_extra={"sse_event": "template_deleted"})
 async def delete_template(
     project_id: UUID,
     template_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""
+    result = session.execute(
+        select(SlotTemplate.id, SlotTemplate.commission_id).where(
+            SlotTemplate.id == template_id, SlotTemplate.project_id == project_id
+        )
+    ).first()
+    if result is None:
+        raise HTTPException(status_code=404, detail="Template not found")
+    if result[1] is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, result[1])
+
     session.execute(
         delete(SlotTemplate).where(
             (SlotTemplate.id == template_id) & (SlotTemplate.project_id == project_id)

+ 105 - 7
app/api/endpoints/users.py

@@ -1,11 +1,23 @@
+import hashlib
+from datetime import UTC, datetime
+
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
-from app.core.security import get_password_hash
-from app.models import User
-from app.schemas.requests import UserCreateRequest, UserUpdatePasswordRequest
+from app.api.utils import issue_reset_token
+from app.core.config import settings
+from app.core.email import EmailSender, get_email_sender
+from app.core.security import get_password_hash, is_reset_rate_limited, log_reset_request
+from app.models import GlobalRole, PasswordResetToken, User, UserOrganization
+from app.schemas.requests import (
+    ForgotPasswordRequest,
+    SetPasswordWithTokenRequest,
+    UserCreateRequest,
+    UserProfileUpdateRequest,
+    UserUpdatePasswordRequest,
+)
 from app.schemas.responses import UserResponse
 
 router = APIRouter()
@@ -16,8 +28,27 @@ async def list_users(
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Returns the list of users from the application"""
-    return session.execute(select(User)).scalars().all()
+    """Returns users. super_admin sees everyone; anyone else sees only users
+    who share at least one organization with them -- enough to find teammates
+    when adding commission members or org roles, without leaking the full
+    cross-organization user directory."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        return (
+            session.execute(select(User).options(selectinload(User.organizations))).scalars().all()
+        )
+
+    org_ids = [m.organization_id for m in current_user.organizations]
+    if not org_ids:
+        return [current_user]
+
+    result = session.execute(
+        select(User)
+        .join(UserOrganization, UserOrganization.user_id == User.id)
+        .where(UserOrganization.organization_id.in_(org_ids))
+        .options(selectinload(User.organizations))
+        .distinct()
+    )
+    return result.scalars().all()
 
 
 @router.get("/me", response_model=UserResponse)
@@ -28,6 +59,26 @@ async def read_current_user(
     return current_user
 
 
+@router.post("/me", response_model=UserResponse)
+async def update_current_user_profile(
+    payload: UserProfileUpdateRequest,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Update the caller's own display name and/or contact phone number.
+    This is the only place phone_number is ever written -- commission
+    contact info (Commission.contacts) is derived from it, not stored
+    separately."""
+    if payload.name is not None:
+        current_user.name = payload.name
+    if payload.phone_number is not None:
+        current_user.phone_number = payload.phone_number
+    session.add(current_user)
+    session.commit()
+    session.refresh(current_user)
+    return current_user
+
+
 @router.delete("/me", status_code=204)
 async def delete_current_user(
     current_user: User = Depends(deps.get_current_user),
@@ -56,14 +107,61 @@ async def register_new_user(
     new_user: UserCreateRequest,
     session: Session = Depends(deps.get_session),
 ):
-    """Create new user"""
+    """Create new user. Does not attach the user to any organization --
+    a super_admin must add them via POST /organizations/{id}/members
+    afterward."""
     result = session.execute(select(User).where(User.email == new_user.email))
     if result.scalars().first() is not None:
         raise HTTPException(status_code=400, detail="Cannot use this email address")
     user = User(
         email=new_user.email,
+        name=new_user.name,
         hashed_password=get_password_hash(new_user.password),
     )
     session.add(user)
     session.commit()
+    session.refresh(user)
+    return user
+
+
+@router.post("/forgot-password", status_code=204)
+async def forgot_password(
+    payload: ForgotPasswordRequest,
+    session: Session = Depends(deps.get_session),
+    email_sender: EmailSender = Depends(get_email_sender),
+):
+    """Always returns 204 regardless of whether the email exists --
+    prevents account enumeration via response-timing/content differences."""
+    log_reset_request(session, payload.email)
+
+    if is_reset_rate_limited(session, payload.email):
+        return
+    user = session.scalar(select(User).where(User.email == payload.email))
+    if user is not None:
+        await issue_reset_token(session, user, "reset", email_sender, settings.FRONT_END_URL)
+
+
+@router.post("/set-password", response_model=UserResponse)
+async def set_password_with_token(
+    payload: SetPasswordWithTokenRequest,
+    session: Session = Depends(deps.get_session),
+):
+    """Consumes a reset or invite token, sets the password, marks it used."""
+    token_hash = hashlib.sha256(payload.token.encode()).hexdigest()
+    reset_token = session.scalar(
+        select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
+    )
+    if (
+        reset_token is None
+        or reset_token.used_at is not None
+        or reset_token.expires_at < datetime.now(UTC)
+    ):
+        raise HTTPException(status_code=400, detail="Invalid or expired link")
+
+    user = session.get(User, reset_token.user_id)
+    user.hashed_password = get_password_hash(payload.password)
+    user.must_change_password = False
+    reset_token.used_at = datetime.now(UTC)
+    session.commit()
+    session.refresh(user)
     return user

+ 198 - 0
app/api/endpoints/volunteer_groups.py

@@ -0,0 +1,198 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.api import deps
+from app.api.utils import (  # adjust import path if different
+    assert_project_exists_or_404,
+    verify_id_list,
+)
+from app.models import OrgRole, Sms, User, Volunteer, VolunteerGroup
+from app.schemas.requests import (
+    GroupMembershipRequest,
+    GroupSmsRequest,
+    VolunteerGroupCreateRequest,
+    VolunteerGroupUpdateRequest,
+)
+from app.schemas.responses import SMSResponse, VolunteerGroupResponse
+
+router = APIRouter(prefix="/project/{project_id}", tags=["volunteer_groups"])
+
+MANAGE_GROUPS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
+
+def _get_group_or_404(session: Session, project_id: UUID, group_id: UUID) -> VolunteerGroup:
+    group = session.get(VolunteerGroup, group_id)
+    if group is None or group.project_id != str(project_id):
+        raise HTTPException(status_code=404, detail="Group not found")
+    return group
+
+
+@router.get("/groups", response_model=list[VolunteerGroupResponse])
+async def list_project_groups(
+    project_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """List volunteer groups from project"""
+    assert_project_exists_or_404(session, project_id)
+    results = session.execute(select(VolunteerGroup).where(VolunteerGroup.project_id == project_id))
+    return results.scalars().all()
+
+
+@router.post(
+    "/group",
+    response_model=VolunteerGroupResponse,
+    openapi_extra={"sse_event": "volunteer_group_created"},
+)
+async def create_group(
+    project_id: UUID,
+    new_group: VolunteerGroupCreateRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Create a new volunteer group in the project"""
+    assert_project_exists_or_404(session, project_id)
+    group = VolunteerGroup(project_id=project_id, **new_group.model_dump())
+    session.add(group)
+    session.commit()
+    session.refresh(group)
+    return group
+
+
+@router.get(
+    "/group/{group_id}",
+    response_model=VolunteerGroupResponse,
+)
+async def get_group(
+    project_id: UUID,
+    group_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Get a single volunteer group"""
+    return _get_group_or_404(session, project_id, group_id)
+
+
+@router.post(
+    "/group/{group_id}",
+    response_model=VolunteerGroupResponse,
+    openapi_extra={"sse_event": "volunteer_group_updated"},
+)
+async def update_group(
+    project_id: UUID,
+    group_id: UUID,
+    edit_group: VolunteerGroupUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Edit a volunteer group"""
+    group = _get_group_or_404(session, project_id, group_id)
+    group.name = edit_group.name
+    group.color = edit_group.color
+    session.commit()
+    session.refresh(group)
+    return group
+
+
+@router.delete(
+    "/group/{group_id}",
+    openapi_extra={"sse_event": "volunteer_group_deleted"},
+)
+async def delete_group(
+    project_id: UUID,
+    group_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Delete a volunteer group (does not delete its volunteers)"""
+    group = _get_group_or_404(session, project_id, group_id)
+    session.delete(group)
+    session.commit()
+
+
+@router.post(
+    "/group/{group_id}/volunteers",
+    response_model=VolunteerGroupResponse,
+    openapi_extra={"sse_event": "volunteer_group_updated"},
+)
+async def add_volunteers_to_group(
+    project_id: UUID,
+    group_id: UUID,
+    payload: GroupMembershipRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Bulk-add volunteers to a group (idempotent -- already-members are skipped)"""
+    group = _get_group_or_404(session, project_id, group_id)
+
+    await verify_id_list(
+        session, payload.volunteer_ids, project_id, Volunteer, "Invalid volunteer list"
+    )
+
+    existing_ids = {v.id for v in group.volunteers}
+    for vid in payload.volunteer_ids:
+        if str(vid) in existing_ids:
+            continue
+        volunteer = session.get(Volunteer, vid)
+        group.volunteers.append(volunteer)
+
+    session.commit()
+    session.refresh(group)
+    return group
+
+
+@router.delete(
+    "/group/{group_id}/volunteer/{volunteer_id}",
+    response_model=VolunteerGroupResponse,
+    openapi_extra={"sse_event": "volunteer_group_updated"},
+)
+async def remove_volunteer_from_group(
+    project_id: UUID,
+    group_id: UUID,
+    volunteer_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Remove a single volunteer from a group"""
+    group = _get_group_or_404(session, project_id, group_id)
+    volunteer = session.get(Volunteer, volunteer_id)
+    if volunteer is not None and volunteer in group.volunteers:
+        group.volunteers.remove(volunteer)
+        session.commit()
+        session.refresh(group)
+    return group
+
+
+@router.post("/group/{group_id}/send-sms", response_model=list[SMSResponse])
+async def send_sms_to_group(
+    project_id: UUID,
+    group_id: UUID,
+    payload: GroupSmsRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Sends an SMS immediately to every group member who has automatic_sms
+    enabled and a phone number on file."""
+    group = _get_group_or_404(session, project_id, group_id)
+
+    sms_list = []
+    for volunteer in group.volunteers:
+        if not volunteer.automatic_sms or not volunteer.phone_number:
+            continue
+        content = payload.content.replace("{prenom}", volunteer.name).replace(
+            "{nom}", volunteer.surname
+        )
+        sms = Sms(
+            project_id=project_id,
+            volunteer_id=volunteer.id,
+            content=content,
+            phone_number=volunteer.phone_number,
+        )
+        sms_list.append(sms)
+
+    session.add_all(sms_list)
+    session.commit()
+    return sms_list

+ 24 - 21
app/api/endpoints/volunteers.py

@@ -6,42 +6,41 @@ from sqlalchemy.orm import Session
 
 from app.api import deps
 from app.api.utils import update_object_from_payload, verify_id_list
-from app.models import Project, Slot, User, Volunteer, association_table_volunteer_slot
+from app.models import OrgRole, Slot, User, Volunteer, association_table_volunteer_slot
 from app.schemas.requests import VolunteerCreateRequest, VolunteerUpdateRequest
 from app.schemas.responses import VolunteerResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["volunteers"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
 
 @router.get("/volunteers", response_model=list[VolunteerResponse])
 async def list_project_volunteers(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
-    """List volunteers from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
+    """List volunteers from project.
+    respo_commission has read-only access -- they need to see who is
+    assigned to work with them, but do not manage volunteer records."""
     results = session.execute(select(Volunteer).where(Volunteer.project_id == project_id))
     return results.scalars().all()
 
 
-@router.post("/volunteer", response_model=VolunteerResponse)
+@router.post(
+    "/volunteer", response_model=VolunteerResponse, openapi_extra={"sse_event": "volunteer_created"}
+)
 async def create_volunteer(
     project_id: UUID,
     new_volunteer: VolunteerCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new volunteer to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
     input_dict = new_volunteer.model_dump()
 
-    # Extract slots list from input dict
     slots: list[UUID] = []
     if input_dict["slots"] is not None:
         slots = input_dict["slots"]
@@ -50,7 +49,6 @@ async def create_volunteer(
 
     volunteer = Volunteer(project_id=project_id, **input_dict)
     session.add(volunteer)
-    # commit to optain an id for the volunteer
     session.commit()
 
     if len(slots) > 0:
@@ -60,15 +58,20 @@ async def create_volunteer(
             )
         )
     session.commit()
+    session.refresh(volunteer)
     return volunteer
 
 
-@router.post("/volunteer/{volunteer_id}", response_model=VolunteerResponse)
+@router.post(
+    "/volunteer/{volunteer_id}",
+    response_model=VolunteerResponse,
+    openapi_extra={"sse_event": "volunteer_updated"},
+)
 async def update_volunteer(
     project_id: UUID,
     volunteer_id: UUID,
     new_volunteer: VolunteerUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a volunteer from the project"""
@@ -77,17 +80,14 @@ async def update_volunteer(
         raise HTTPException(status_code=404, detail="Volunteer not found")
 
     input_dict = new_volunteer.model_dump(exclude_unset=True)
-    # Extract slots list from input dict
     if "slots" in input_dict:
         slots: list[UUID] = input_dict["slots"]
         await verify_id_list(session, slots, project_id, Slot, "Invalid slot list")
-        # Remove previous values
         session.execute(
             association_table_volunteer_slot.delete().where(
                 association_table_volunteer_slot.c.volunteer_id == volunteer.id
             )
         )
-        # Add the new slots
         if len(slots) > 0:
             session.execute(
                 association_table_volunteer_slot.insert().values(
@@ -102,11 +102,14 @@ async def update_volunteer(
     return volunteer
 
 
-@router.delete("/volunteer/{volunteer_id}")
+@router.delete(
+    "/volunteer/{volunteer_id}",
+    openapi_extra={"sse_event": "volunteer_deleted"},
+)
 async def delete_volunteer(
     project_id: UUID,
     volunteer_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a volunteer from the project"""

+ 69 - 1
app/api/utils.py

@@ -1,10 +1,41 @@
+import logging
+from datetime import UTC, datetime
 from uuid import UUID
 
 from fastapi import HTTPException
-from sqlalchemy import select
+from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.sql import func
 
+from app.core.email import EmailDeliveryError, EmailSender
+from app.core.security import RESET_TOKEN_TTL, generate_reset_token
+from app.models import PasswordResetToken, Project, User
+
+logger = logging.getLogger(__name__)
+
+
+def assert_project_exists_or_404(session: Session, project_id: UUID) -> None:
+    if not session.execute(select(exists().where(Project.id == project_id))).scalar():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+
+def get_project_or_404(session: Session, project_id: UUID) -> Project:
+    p = session.get(Project, project_id)
+    if p is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    return p
+
+
+def get_project_organization_id(session: Session, project_id: UUID) -> str:
+    """Fetches only Project.organization_id -- avoids loading the full
+    Project row (and its relationships) just to check existence/ownership."""
+    org_id = session.execute(
+        select(Project.organization_id).where(Project.id == project_id)
+    ).scalar_one_or_none()
+    if org_id is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    return org_id
+
 
 async def verify_id_list(
     session: Session,
@@ -38,3 +69,40 @@ def update_object_from_payload(obj, payload: dict):
     """Update the ORM model object from a pydantic payload dictionary"""
     for attr_name, value in payload.items():
         setattr(obj, attr_name, value)
+
+
+async def issue_reset_token(
+    session: Session, user: User, purpose: str, email_sender: EmailSender, base_url: str
+) -> None:
+    raw_token, token_hash = generate_reset_token()
+    session.add(
+        PasswordResetToken(
+            user_id=user.id,
+            token_hash=token_hash,
+            expires_at=datetime.now(UTC) + RESET_TOKEN_TTL[purpose],
+            purpose=purpose,
+        )
+    )
+    session.commit()
+
+    link = f"{base_url}/set-password?token={raw_token}"
+    if purpose == "invite":
+        subject, body = (
+            "Vous avez été invité·e",
+            f"Créez votre mot de passe : <a href='{link}'>{link}</a>",
+        )
+    else:
+        subject, body = (
+            "Réinitialisation du mot de passe",
+            f"Réinitialisez ici : <a href='{link}'>{link}</a>",
+        )
+    try:
+        await email_sender.send(user.email, subject, body)
+    except EmailDeliveryError as exc:
+        logger.error(
+            "Failed to send %s email to user_id=%s email=%s: %s",
+            purpose,
+            user.id,
+            user.email,
+            exc,
+        )

+ 16 - 2
app/core/config.py

@@ -37,7 +37,10 @@ def build_postgreuri(
 
 class Settings(BaseSettings):
     # CORE SETTINGS
+    FRONT_END_URL: str
     SECRET_KEY: str
+    RESET_REQUEST_LIMIT: int = 3
+    RESET_REQUEST_WINDOW_HOUR: int = 1
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     SECURITY_BCRYPT_ROUNDS: int = 12
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
@@ -45,13 +48,25 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
 
-    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS:int = 180
+    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS: int = 180
 
     # PROJECT NAME, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     DESCRIPTION: str = PYPROJECT_CONTENT["description"]
 
+    # Email account to send reset password
+    SMTP_HOST: str = ""
+    SMTP_PORT: int = 587
+    SMTP_USERNAME: str = ""
+    SMTP_PASSWORD: str = ""
+    SMTP_USE_TLS: bool = True
+    EMAIL_FROM_ADDRESS: str = "no-reply@example.com"
+    EMAIL_FROM_NAME: str = "BDLG Planner"
+
+    # REDIS
+    REDIS_URL: str = "redis://localhost:6379"
+
     # POSTGRESQL DEFAULT DATABASE
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_USER: str
@@ -98,5 +113,4 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
-
 settings: Settings = Settings()  # type: ignore

+ 63 - 0
app/core/email.py

@@ -0,0 +1,63 @@
+import logging
+from abc import ABC, abstractmethod
+from email.message import EmailMessage
+
+import aiosmtplib
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class EmailSender(ABC):
+    @abstractmethod
+    async def send(self, to: str, subject: str, html_body: str) -> None: ...
+
+
+class LoggingEmailSender(EmailSender):
+    """Dev/test fallback: logs instead of sending. Wire a real provider
+    (SES, Postmark, SMTP) behind this interface before going to prod."""
+
+    async def send(self, to: str, subject: str, html_body: str) -> None:
+        logger.info(f"[EMAIL to={to}] {subject}\n{html_body}")
+
+
+class SmtpEmailSender(EmailSender):
+    """Sends via SMTP. Works with any provider that exposes SMTP
+    credentials (SES, Postmark, Sendgrid, a plain mailbox, etc)."""
+
+    async def send(self, to: str, subject: str, html_body: str) -> None:
+        message = EmailMessage()
+        message["From"] = f"{settings.EMAIL_FROM_NAME} <{settings.EMAIL_FROM_ADDRESS}>"
+        message["To"] = to
+        message["Subject"] = subject
+        message.set_content("Ce message nécessite un client compatible HTML.")
+        message.add_alternative(html_body, subtype="html")
+
+        try:
+            await aiosmtplib.send(
+                message,
+                hostname=settings.SMTP_HOST,
+                port=settings.SMTP_PORT,
+                username=settings.SMTP_USERNAME or None,
+                password=settings.SMTP_PASSWORD or None,
+                start_tls=settings.SMTP_USE_TLS,
+                timeout=10,
+            )
+        except (aiosmtplib.SMTPException, OSError) as exc:
+            # Don't let a transient SMTP outage 500 the caller's whole
+            # request (e.g. invite-member already committed the DB write) --
+            # log loudly so it's visible in monitoring, and let the caller
+            # decide whether to surface a degraded-but-successful response.
+            logger.error("Failed to send email to %s: %s", to, exc)
+            raise EmailDeliveryError(str(exc)) from exc
+
+
+class EmailDeliveryError(Exception):
+    pass
+
+
+def get_email_sender() -> EmailSender:
+    if settings.ENVIRONMENT == "PYTEST" or not settings.SMTP_HOST:
+        return LoggingEmailSender()
+    return SmtpEmailSender()

+ 6 - 0
app/core/events.py

@@ -0,0 +1,6 @@
+from broadcaster import Broadcast
+
+from app.core.config import settings
+
+# Instantiate the broadcaster using the URL from your environment variables
+broadcast = Broadcast(settings.REDIS_URL)

+ 36 - 3
app/core/security.py

@@ -1,12 +1,18 @@
-"""Black-box security shortcuts to generate JWT tokens and password hashing and verifcation."""
+"""Black-box security shortcuts to generate JWT tokens and password hashing and verification."""
 
+import hashlib
+import secrets
 import time
+from datetime import UTC, datetime, timedelta
 
 import jwt
 from passlib.context import CryptContext
 from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
 
 from app.core import config
+from app.models import PasswordResetRequestLog
 from app.schemas.responses import AccessTokenResponse
 
 JWT_ALGORITHM = "HS256"
@@ -26,6 +32,15 @@ class JWTTokenPayload(BaseModel):
     expires_at: int
 
 
+RESET_TOKEN_TTL = {"reset": timedelta(hours=1), "invite": timedelta(days=7)}
+
+
+def generate_reset_token() -> tuple[str, str]:
+    """Returns (raw_token_for_email, sha256_hash_for_db)."""
+    raw = secrets.token_urlsafe(32)
+    return raw, hashlib.sha256(raw.encode()).hexdigest()
+
+
 def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool):
     """Creates jwt access or refresh token for user.
 
@@ -74,7 +89,7 @@ def generate_access_token_response(subject: str | int):
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verifies plain and hashed password matches
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     return PWD_CONTEXT.verify(plain_password, hashed_password)
@@ -83,7 +98,25 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
 def get_password_hash(password: str) -> str:
     """Creates hash from password
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     return PWD_CONTEXT.hash(password)
+
+
+def is_reset_rate_limited(session: Session, email: str) -> bool:
+    window_start = datetime.now(UTC) - timedelta(hours=config.settings.RESET_REQUEST_WINDOW_HOUR)
+    count = session.scalar(
+        select(func.count())
+        .select_from(PasswordResetRequestLog)
+        .where(
+            PasswordResetRequestLog.email == email,
+            PasswordResetRequestLog.requested_at >= window_start,
+        )
+    )
+    return count >= config.settings.RESET_REQUEST_LIMIT
+
+
+def log_reset_request(session: Session, email: str) -> None:
+    session.add(PasswordResetRequestLog(email=email))
+    session.commit()

+ 3 - 3
app/create_sms_batch.py

@@ -1,10 +1,10 @@
-""" """
+from datetime import datetime, timedelta
 
 from sqlalchemy import select
-from datetime import datetime, timedelta
+
 from app.core import config
 from app.core.session import session
-from app.models import Sms, Project
+from app.models import Project, Sms
 
 TEST_SMS_PROJECT_NAME = "test_project pour sms"
 NUMBER_OF_SMS = 80

+ 4 - 3
app/initial_data.py

@@ -9,7 +9,7 @@ from sqlalchemy import select
 
 from app.core import config, security
 from app.core.session import session
-from app.models import User
+from app.models import GlobalRole, User
 
 
 def main() -> None:
@@ -24,13 +24,14 @@ def main() -> None:
                 hashed_password=security.get_password_hash(
                     config.settings.FIRST_SUPERUSER_PASSWORD
                 ),
+                global_role=GlobalRole.SUPER_ADMIN,
             )
             db.add(new_superuser)
-            db.commit()
             print("Superuser was created")
         else:
             print("Superuser already exists in database")
-
+            user.global_role = GlobalRole.SUPER_ADMIN
+        db.commit()
         print("Initial data created")
 
 

+ 101 - 1
app/main.py

@@ -1,11 +1,24 @@
 """Main FastAPI app instance declaration."""
 
+import typing
+from contextlib import asynccontextmanager
+
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.trustedhost import TrustedHostMiddleware
+from fastapi.openapi.utils import get_openapi
 
 from app.api.api import api_router
 from app.core import config
+from app.core.events import broadcast
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    await broadcast.connect()
+    yield
+    await broadcast.disconnect()
+
 
 app = FastAPI(
     title=config.settings.PROJECT_NAME,
@@ -13,6 +26,7 @@ app = FastAPI(
     description=config.settings.DESCRIPTION,
     openapi_url="/openapi.json",
     docs_url="/",
+    lifespan=lifespan,
 )
 app.include_router(api_router)
 
@@ -26,4 +40,90 @@ app.add_middleware(
 )
 
 # Guards against HTTP Host Header attacks
-app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)
+if config.settings.ENVIRONMENT != "PYTEST":
+    app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)
+
+
+def custom_openapi():
+    if app.openapi_schema:
+        return app.openapi_schema
+
+    openapi_schema = get_openapi(
+        title=config.settings.PROJECT_NAME,
+        version=config.settings.VERSION,
+        description=config.settings.DESCRIPTION,
+        routes=app.routes,
+    )
+
+    one_of_schemas = []
+
+    for route in app.routes:
+        if hasattr(route, "openapi_extra") and route.openapi_extra:
+            event_name = route.openapi_extra.get("sse_event")
+            if not event_name:
+                continue
+
+            method = list(route.methods)[0] if route.methods else "GET"
+            payload_schema = {}
+
+            # Determine payload schema for DELETE (Path params)
+            model = getattr(route, "response_model", None)
+            if method == "DELETE" and model is None:
+                props = {}
+                for param in getattr(route.dependant, "path_params", []):
+                    props[param.name] = {"type": "string"}
+                payload_schema = {"type": "object", "properties": props}
+
+            # Determine payload schema for POST/PUT (Response Model)
+            else:
+                origin = typing.get_origin(model)
+                if origin is list:
+                    item_model = typing.get_args(model)[0]
+                    if hasattr(item_model, "__name__"):
+                        model_name = item_model.__name__
+                        payload_schema = {
+                            "type": "array",
+                            "items": {"$ref": f"#/components/schemas/{model_name}"},
+                        }
+                elif hasattr(model, "__name__"):
+                    model_name = model.__name__
+                    payload_schema = {"$ref": f"#/components/schemas/{model_name}"}
+
+            # Build the discriminated union object with traceability in the description
+            event_schema = {
+                "type": "object",
+                "description": f"Triggered by `{method}` `{route.path}`",
+                "properties": {
+                    "event": {"type": "string", "enum": [event_name]},
+                    "data": payload_schema,
+                },
+                "required": ["event", "data"],
+            }
+            one_of_schemas.append(event_schema)
+
+    if one_of_schemas:
+        if "components" not in openapi_schema:
+            openapi_schema["components"] = {"schemas": {}}
+        elif "schemas" not in openapi_schema["components"]:
+            openapi_schema["components"]["schemas"] = {}
+
+        openapi_schema["components"]["schemas"]["SseEventPayload"] = {
+            "title": "SseEventPayload",
+            "description": "Discriminated union of all possible SSE events and their payloads.",
+            "oneOf": one_of_schemas,
+        }
+
+        for path, path_item in openapi_schema["paths"].items():
+            if path.endswith("/stream") and "get" in path_item:
+                path_item["get"]["responses"]["200"]["content"] = {
+                    "text/event-stream": {
+                        "schema": {"$ref": "#/components/schemas/SseEventPayload"}
+                    }
+                }
+
+    app.openapi_schema = openapi_schema
+    return app.openapi_schema
+
+
+# 5. Override the default OpenAPI method
+app.openapi = custom_openapi

+ 202 - 18
app/models.py

@@ -14,11 +14,12 @@ alembic revision --autogenerate -m "migration_name"
 alembic upgrade head
 """
 
-from typing import Optional
+import enum
 import uuid
 from datetime import datetime
 
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
+from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Table
+from sqlalchemy import Enum as SAEnum
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -34,23 +35,120 @@ def uid_column() -> Mapped[str]:
     return mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()))
 
 
+class GlobalRole(str, enum.Enum):
+    SUPER_ADMIN = "super_admin"
+    USER = "user"
+
+
+class OrgRole(str, enum.Enum):
+    ORG_ADMIN = "org_admin"
+    RESPO_BENEVOLE = "respo_benevole"
+    RESPO_COMMISSION = "respo_commission"
+
+
+association_table_commission_member = Table(
+    "commission_members",
+    Base.metadata,
+    Column("commission_id", ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True),
+    Column("user_id", ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
 class User(Base):
     __tablename__ = "user_model"
-
-    id: Mapped[str] = uid_column()
+    id: Mapped[UUID] = uid_column()
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
+    must_change_password: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
+    name: Mapped[str] = mapped_column(String(128), default="")
+    phone_number: Mapped[str | None] = mapped_column(String(24), nullable=True)
+    global_role: Mapped[GlobalRole] = mapped_column(
+        SAEnum(GlobalRole, name="global_role"), default=GlobalRole.USER, nullable=False
+    )
+
+    organizations: Mapped[list["UserOrganization"]] = relationship(
+        back_populates="user", cascade="all, delete-orphan"
+    )
+    commissions: Mapped[list["Commission"]] = relationship(
+        secondary=association_table_commission_member, back_populates="members"
+    )
+
+
+class PasswordResetToken(Base):
+    __tablename__ = "password_reset_tokens"
+    id: Mapped[UUID] = uid_column()
+    user_id: Mapped[UUID] = mapped_column(
+        ForeignKey("user_model.id", ondelete="CASCADE"), index=True
+    )
+    user: Mapped["User"] = relationship()
+    token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)  # sha256 hex
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+    used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+    purpose: Mapped[str] = mapped_column(String(32))
+
+
+class PasswordResetRequestLog(Base):
+    """Lightweight rate-limit ledger — not user-facing, no need for a UUID PK."""
+
+    __tablename__ = "password_reset_request_log"
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    email: Mapped[str] = mapped_column(String(254), index=True)
+    requested_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
+
+
+class Organization(Base):
+    __tablename__ = "organizations"
+    id: Mapped[UUID] = uid_column()
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now(), nullable=True
+    )
+    name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
+
+    projects: Mapped[list["Project"]] = relationship(
+        back_populates="organization", cascade="all, delete-orphan"
+    )
+    memberships: Mapped[list["UserOrganization"]] = relationship(
+        back_populates="organization",
+        cascade="all, delete-orphan",
+        passive_deletes=True,  # let the DB's ON DELETE CASCADE do the actual delete
+    )
+
+
+class UserOrganization(Base):
+    """Many-to-many: a user can belong to several orgs, one role per org."""
+
+    __tablename__ = "user_organizations"
+    user_id: Mapped[UUID] = mapped_column(
+        ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True
+    )
+    organization_id: Mapped[UUID] = mapped_column(
+        ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
+    )
+    role: Mapped[OrgRole] = mapped_column(SAEnum(OrgRole, name="org_role"), nullable=False)
+
+    user: Mapped["User"] = relationship(back_populates="organizations")
+    organization: Mapped["Organization"] = relationship(back_populates="memberships")
 
 
 class Project(Base):
     __tablename__ = "projects"
-    id: Mapped[str] = uid_column()
+    id: Mapped[UUID] = uid_column()
+    organization_id: Mapped[UUID] = mapped_column(
+        ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
+    )
+    organization: Mapped["Organization"] = relationship(back_populates="projects")
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
     name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
     is_public: Mapped[bool] = mapped_column(Boolean())
+    constraints: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
+
     volunteers: Mapped[list["Volunteer"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
@@ -66,6 +164,42 @@ class Project(Base):
     tags: Mapped[list["SlotTag"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
+    commissions: Mapped[list["Commission"]] = relationship(
+        back_populates="project", cascade="delete, delete-orphan"
+    )
+    groups: Mapped[list["VolunteerGroup"]] = relationship(
+        back_populates="project", cascade="all, delete-orphan", passive_deletes=True
+    )
+
+
+class Commission(Base):
+    __tablename__ = "commissions"
+    id: Mapped[UUID] = uid_column()
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now()
+    )
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project: Mapped["Project"] = relationship(back_populates="commissions")
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+
+    templates: Mapped[list["SlotTemplate"]] = relationship(back_populates="commission")
+    members: Mapped[list["User"]] = relationship(
+        secondary=association_table_commission_member, back_populates="commissions"
+    )
+
+    @hybrid_property
+    def contacts(self) -> list[dict]:
+        """Read-only, derived from members' own profile — never stored redundantly."""
+        return [{"name": m.name, "phone_number": m.phone_number} for m in self.members]
+
+    @hybrid_property
+    def members_id(self) -> list[UUID]:
+        return [u.id for u in self.members]
+
+    @hybrid_property
+    def templates_id(self) -> list[UUID]:
+        return [t.id for t in self.templates]
 
 
 association_table_volunteer_slot = Table(
@@ -79,11 +213,39 @@ association_table_volunteer_slot = Table(
     Column("slot_id", ForeignKey("slots.id", ondelete="CASCADE"), primary_key=True),
 )
 
+association_table_volunteer_group = Table(
+    "association_volunteer_group",
+    Base.metadata,
+    Column("volunteer_id", ForeignKey("volunteers.id", ondelete="CASCADE"), primary_key=True),
+    Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
+class VolunteerGroup(Base):
+    __tablename__ = "volunteer_groups"
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project: Mapped["Project"] = relationship()
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now()
+    )
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    color: Mapped[str | None] = mapped_column(String(16), nullable=True)  # for UI chips later
+
+    volunteers: Mapped[list["Volunteer"]] = relationship(
+        secondary=association_table_volunteer_group, back_populates="groups"
+    )
+
+    @hybrid_property
+    def volunteers_id(self) -> list[str]:
+        return [v.id for v in self.volunteers]
+
 
 class Volunteer(Base):
     __tablename__ = "volunteers"
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="volunteers")
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(
@@ -100,6 +262,10 @@ class Volunteer(Base):
     comment: Mapped[str] = mapped_column(String(), default="")
     sms: Mapped[list["Sms"]] = relationship(back_populates="volunteer", cascade="all, delete")
 
+    groups: Mapped[list["VolunteerGroup"]] = relationship(
+        secondary=association_table_volunteer_group, back_populates="volunteers"
+    )
+
     @hybrid_property
     def slots_id(self) -> list[str]:
         return [s.id for s in self.slots]
@@ -108,8 +274,8 @@ class Volunteer(Base):
 class Slot(Base):
     __tablename__ = "slots"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="slots")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -127,7 +293,7 @@ class Slot(Base):
         secondary=association_table_volunteer_slot, back_populates="slots"
     )
 
-    template_id: Mapped[Optional[str]] = mapped_column(
+    template_id: Mapped[UUID | None] = mapped_column(
         ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
     )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
@@ -152,8 +318,8 @@ association_table_template_tags = Table(
 class SlotTag(Base):
     __tablename__ = "slot_tags"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="tags")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -174,8 +340,8 @@ class SlotTag(Base):
 class SlotTemplate(Base):
     __tablename__ = "slot_templates"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="templates")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -185,7 +351,11 @@ class SlotTemplate(Base):
     title: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     place: Mapped[str] = mapped_column(String(), default="")
-    responsible_contact: Mapped[str] = mapped_column(String(), default="")
+    commission_id: Mapped[str | None] = mapped_column(
+        ForeignKey("commissions.id", ondelete="SET NULL"), nullable=True
+    )
+    commission: Mapped["Commission"] = relationship(back_populates="templates")
+    responsible_override: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
 
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
@@ -203,6 +373,18 @@ class SlotTemplate(Base):
     def tags_id(self) -> list[str]:
         return [s.id for s in self.tags]
 
+    @hybrid_property
+    def effective_responsible_contact(self) -> str:
+        """What SMS/templates should actually display as {respo}."""
+        if self.responsible_override:
+            return self.responsible_override
+        if self.commission is not None:
+            contacts = self.commission.contacts  # [{"name":..., "phone_number":...}, ...]
+            return ", ".join(
+                f"{c['name']} : {c['phone_number']}" for c in contacts if c.get("phone_number")
+            )
+        return ""
+
 
 class Sms(Base):
     __tablename__ = "sms"
@@ -210,10 +392,10 @@ class Sms(Base):
     id: Mapped[str] = mapped_column(
         UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
     )
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="sms")
 
-    volunteer_id: Mapped[str] = mapped_column(
+    volunteer_id: Mapped[UUID] = mapped_column(
         ForeignKey("volunteers.id", ondelete="CASCADE", onupdate="CASCADE"),
         nullable=True,
     )
@@ -232,6 +414,8 @@ class ServerStatus(Base):
     __tablename__ = "server_status"
     # Use a fixed ID to ensure we only ever have one row
     id = Column(Integer, primary_key=True, default=1)
-    updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now())
+    updated_at = Column(
+        DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now()
+    )
     host = Column(String, nullable=False)
     user_agent = Column(String)

+ 13 - 0
app/schemas/objects.py

@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+
+
+class PlanningConstraints(BaseModel):
+    dejeuner_start: int = 11
+    dejeuner_end: int = 14
+    diner_start: int = 19
+    diner_end: int = 22
+    nuit_start: int = 22
+    nuit_end: int = 10
+    duree_repas_h: float = 1.0
+    duree_nuit_h: float = 8.0
+    duree_pause_h: float = 0.5

+ 129 - 45
app/schemas/requests.py

@@ -1,42 +1,94 @@
 import datetime
-from typing import Literal, Optional
+from typing import Literal
 
 from pydantic import UUID4, BaseModel, EmailStr, Field
 
+from app.models import OrgRole
+from app.schemas.objects import PlanningConstraints
+
 
 class BaseRequest(BaseModel):
     # may define additional fields or config shared across requests
     pass
 
 
+class ForgotPasswordRequest(BaseRequest):
+    email: EmailStr
+
+
+class SetPasswordWithTokenRequest(BaseRequest):
+    token: str
+    password: str
+
+
 class RefreshTokenRequest(BaseRequest):
     refresh_token: str
 
 
-class UserUpdatePasswordRequest(BaseRequest):
+class UserCreateRequest(BaseRequest):
+    email: EmailStr
     password: str
+    name: str
 
 
-class UserCreateRequest(BaseRequest):
+class UserInviteRequest(BaseRequest):
     email: EmailStr
+    name: str
+    phone_number: str | None = None
+    org_role: OrgRole = OrgRole.RESPO_COMMISSION
+
+
+class UserUpdatePasswordRequest(BaseRequest):
     password: str
 
 
+class UserProfileUpdateRequest(BaseRequest):
+    name: str | None = None
+    phone_number: str | None = None
+
+
+class OrganizationRequest(BaseRequest):
+    name: str
+
+
+class OrganizationMembershipCreateRequest(BaseRequest):
+    """Adds an EXISTING user (by email) to an organization with a role.
+    Does not create user accounts — see note below."""
+
+    user_id: UUID4
+    role: OrgRole
+
+
+class OrganizationMembershipUpdateRequest(BaseRequest):
+    role: OrgRole
+
+
 class SmsCreateRequest(BaseRequest):
-    volunteer_id: Optional[UUID4] = None
+    volunteer_id: UUID4 | None = None
     phone_number: str
     content: str
-    sending_time: Optional[datetime.datetime] = None
+    sending_time: datetime.datetime | None = None
 
 
 class SmsUpdateRequest(BaseRequest):
-    volunteer_id: Optional[UUID4] = None
-    phone_number: Optional[str] = None
-    content: Optional[str] = None
-    sending_time: Optional[datetime.datetime] = None
+    volunteer_id: UUID4 | None = None
+    phone_number: str | None = None
+    content: str | None = None
+    sending_time: datetime.datetime | None = None
 
 
-class ProjectRequest(BaseRequest):
+class ProjectCreateRequest(BaseRequest):
+    organization_id: UUID4
+    name: str
+    is_public: bool = Field(
+        default=False,
+        description="Allow anyone to access the information about this project without login",
+    )
+
+class ProjectConstraintUpdateRequest(PlanningConstraints):
+    pass
+
+class ProjectUpdateRequest(BaseRequest):
     name: str
     is_public: bool = Field(
         default=False,
@@ -47,7 +99,7 @@ class ProjectRequest(BaseRequest):
 class ProjectImportGsheetRequest(BaseRequest):
     sheet_url: str
     satursday_date: datetime.datetime
-    erase_data: Optional[bool] = Field(default=False)
+    erase_data: bool | None = Field(default=False)
 
 
 class ProjectSMSBatchRequest(BaseRequest):
@@ -59,7 +111,7 @@ class ProjectSMSBatchRequest(BaseRequest):
      - {description} slot.description
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
+     - {respo} slot.responsible_override
      - {prenom} volunteer.name
      - {nom} volunteer.surname""",
         examples=["Bonjour {prenom},\nTon créneau {titre} commence à {debut}.\nla com bénévole"],
@@ -70,68 +122,100 @@ class ProjectSMSBatchRequest(BaseRequest):
     )
 
 
+class CommissionCreateRequest(BaseModel):
+    name: str
+
+
+class CommissionUpdateRequest(BaseModel):
+    name: str
+
+
+class CommissionMembershipRequest(BaseModel):
+    user_ids: list[UUID4]
+
+
 class VolunteerCreateRequest(BaseRequest):
     name: str
-    surname: Optional[str] = Field(default="")
+    surname: str | None = Field(default="")
     email: EmailStr
     phone_number: str
     automatic_sms: bool = Field(default=True)
-    slots: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    slots: list[UUID4] | None = None
+    comment: str | None = None
 
 
 class VolunteerUpdateRequest(BaseRequest):
-    name: Optional[str] = None
-    surname: Optional[str] = None
-    email: Optional[str] = None
-    phone_number: Optional[str] = None
-    automatic_sms: Optional[bool] = None
-    slots: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    name: str | None = None
+    surname: str | None = None
+    email: str | None = None
+    phone_number: str | None = None
+    automatic_sms: bool | None = None
+    slots: list[UUID4] | None = None
+    comment: str | None = None
+
+
+class VolunteerGroupCreateRequest(BaseRequest):
+    name: str
+    color: str | None = None
+
+
+class VolunteerGroupUpdateRequest(BaseRequest):
+    name: str
+    color: str | None = None
+
+
+class GroupMembershipRequest(BaseRequest):
+    volunteer_ids: list[UUID4]
+
+
+class GroupSmsRequest(BaseRequest):
+    content: str
 
 
 class SlotCreateRequest(BaseRequest):
     title: str
     starting_time: datetime.datetime
     ending_time: datetime.datetime
-    required_volunteers: Optional[int] = 0
-    volunteers: Optional[list[UUID4]] = None
-    template_id: Optional[UUID4] = None
+    required_volunteers: int | None = 0
+    volunteers: list[UUID4] | None = None
+    template_id: UUID4 | None = None
 
 
 class SlotUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    starting_time: Optional[datetime.datetime] = None
-    ending_time: Optional[datetime.datetime] = None
-    required_volunteers: Optional[int] = None
-    volunteers: Optional[list[UUID4]] = None
-    template_id: Optional[UUID4 | Literal[""]] = None
+    title: str | None = None
+    starting_time: datetime.datetime | None = None
+    ending_time: datetime.datetime | None = None
+    required_volunteers: int | None = None
+    volunteers: list[UUID4] | None = None
+    template_id: UUID4 | Literal[""] | None = None
 
 
 class TemplateCreateRequest(BaseRequest):
     title: str
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
-    tags: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    description: str | None = None
+    place: str | None = None
+    responsible_override: str | None = None
+    tags: list[UUID4] | None = None
+    comment: str | None = None
+    commission_id: UUID4 | None = None
 
 
 class TemplateUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
+    title: str | None = None
+    description: str | None = None
+    place: str | None = None
+    responsible_override: str | None = None
     # slots: Optional[list[UUID4]] = None
-    tags: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    tags: list[UUID4] | None = None
+    comment: str | None = None
+    commission_id: UUID4 | None = None
 
 
 class TagCreateRequest(BaseRequest):
     title: str
-    templates: Optional[list[UUID4]] = None
+    templates: list[UUID4] | None = None
 
 
 class TagUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    templates: Optional[list[UUID4]] = None
+    title: str | None = None
+    templates: list[UUID4] | None = None

+ 66 - 12
app/schemas/responses.py

@@ -1,16 +1,17 @@
-from enum import Enum
 from datetime import datetime
-from typing import Optional
-from pydantic import BaseModel, ConfigDict, EmailStr
+from enum import Enum
+
+from pydantic import UUID4, BaseModel, ConfigDict, EmailStr
+
+from app.schemas.objects import PlanningConstraints
 
 
 class BaseResponse(BaseModel):
-    # may define additional fields or config shared across responses
     model_config = ConfigDict(from_attributes=True)
 
 
 class BaseObjectResponse(BaseResponse):
-    id: str
+    id: UUID4
     created_at: datetime
     updated_at: datetime
 
@@ -25,9 +26,55 @@ class AccessTokenResponse(BaseResponse):
     refresh_token_issued_at: int
 
 
+# ---- Organization ----
+class OrganizationResponse(BaseResponse):
+    id: str
+    name: str
+
+
+class MembershipUserResponse(BaseResponse):
+    id: str
+    email: EmailStr
+    name: str
+
+
+class OrganizationMembershipResponse(BaseResponse):
+    user_id: str
+    organization_id: str
+    role: str
+    user: MembershipUserResponse
+
+
+class OrganizationDetailResponse(OrganizationResponse):
+    name: str
+    memberships: list[OrganizationMembershipResponse] = []
+
+
+class UserOrganizationResponse(BaseResponse):
+    organization: OrganizationResponse
+    role: str
+
+
 class UserResponse(BaseResponse):
     id: str
     email: EmailStr
+    name: str
+    phone_number: str | None = None
+    global_role: str
+    organizations: list[UserOrganizationResponse] = []
+    must_change_password: bool
+
+
+class CommissionResponse(BaseObjectResponse):
+    name: str
+    templates_id: list[UUID4] = []
+    members_id: list[UUID4] = []
+
+
+class VolunteerGroupResponse(BaseObjectResponse):
+    name: str
+    color: str | None = None
+    volunteers_id: list[UUID4] = []
 
 
 class VolunteerResponse(BaseObjectResponse):
@@ -36,7 +83,8 @@ class VolunteerResponse(BaseObjectResponse):
     email: str
     phone_number: str
     automatic_sms: bool
-    slots_id: list[str] = []
+    slots_id: list[UUID4] = []
+    groups_id: list[str] = []
     comment: str
 
 
@@ -46,23 +94,24 @@ class SlotResponse(BaseObjectResponse):
     ending_time: datetime
     required_volunteers: int
     volunteers_id: list[str] = []
-    template_id: Optional[str]
+    template_id: UUID4 | None
 
 
 class SMSResponse(BaseObjectResponse):
-    volunteer_id: Optional[str]
+    volunteer_id: UUID4 | None
     content: str
     phone_number: str
     sending_time: datetime
-    send_time: Optional[datetime]
+    send_time: datetime | None
 
 
 class TemplateResponse(BaseObjectResponse):
     title: str
     description: str
     place: str
-    responsible_contact: str
-    tags_id: list[str] = []
+    responsible_override: str | None = None
+    commission_id: str | None = None
+    tags_id: list[UUID4] = []
     comment: str
 
 
@@ -73,19 +122,24 @@ class TagResponse(BaseObjectResponse):
 
 class ProjectResponse(BaseObjectResponse):
     name: str
+    organization_id: str
     volunteers: list[VolunteerResponse]
     sms: list[SMSResponse]
     slots: list[SlotResponse]
     tags: list[TagResponse]
     templates: list[TemplateResponse]
+    groups: list[VolunteerGroupResponse] = []
+    commissions: list[CommissionResponse] = []
+    constraints: PlanningConstraints
     is_public: bool
 
 
 class ProjectListResponse(BaseResponse):
-    id: str
+    id: UUID4
     created_at: datetime
     updated_at: datetime
     name: str
+    organization_id: str
     is_public: bool
 
 

+ 158 - 22
app/tests/conftest.py

@@ -1,19 +1,32 @@
 import asyncio
 from collections.abc import AsyncGenerator
 from datetime import datetime
+from uuid import uuid4
 
 import pytest
 import pytest_asyncio
-from httpx import AsyncClient
+from httpx import ASGITransport, AsyncClient
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.core import config, security
 from app.core.session import engine
 from app.core.session import session as session_maker
-
 from app.main import app
-from app.models import Base, Project, Slot, SlotTag, SlotTemplate, Sms, User, Volunteer
+from app.models import (
+    Base,
+    GlobalRole,
+    Organization,
+    OrgRole,
+    Project,
+    Slot,
+    SlotTag,
+    SlotTemplate,
+    Sms,
+    User,
+    UserOrganization,
+    Volunteer,
+)
 
 default_user_id = "b75365d9-7bf9-4f54-add5-aeab333a087b"
 default_user_email = "geralt@wiedzmin.pl"
@@ -31,6 +44,9 @@ default_sms_id = "b05c2b38-edb1-4e7a-9689-a0cba904ef29"
 default_tag_id = "47709be8-7edb-4fb1-9de0-8a529a91856d"
 default_template_id = "dcfcf2e9-8b1d-4bbf-b44b-aa511a7fe274"
 
+default_organization_id = "a1f2e3d4-5b6c-47d8-9e0f-1a2b3c4d5e6f"
+default_organization_name = "Default Org"
+
 
 @pytest.fixture(scope="session")
 def event_loop():
@@ -42,10 +58,7 @@ def event_loop():
 
 @pytest_asyncio.fixture(scope="session")
 async def test_db_setup_sessionmaker():
-    # assert if we use TEST_DB URL for 100%
     assert config.settings.ENVIRONMENT == "PYTEST"
-
-    # always drop and create test db tables between tests session
     Base.metadata.drop_all(engine)
     Base.metadata.create_all(engine)
 
@@ -54,8 +67,6 @@ async def test_db_setup_sessionmaker():
 async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
     with session_maker() as db:
         yield db
-
-        # delete all data from all tables after test
         for name, table in Base.metadata.tables.items():
             db.execute(delete(table))
         db.commit()
@@ -63,8 +74,7 @@ async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
 
 @pytest_asyncio.fixture(scope="session")
 async def client() -> AsyncGenerator[AsyncClient, None]:
-    async with AsyncClient(app=app, base_url="http://test") as client:
-        client.headers.update({"Host": "localhost"})
+    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
         yield client
 
 
@@ -77,6 +87,7 @@ def default_user(test_db_setup_sessionmaker) -> User:
             new_user = User(
                 email=default_user_email,
                 hashed_password=default_user_password_hash,
+                name="Geralt",
             )
             new_user.id = default_user_id
             db.add(new_user)
@@ -87,13 +98,135 @@ def default_user(test_db_setup_sessionmaker) -> User:
 
 
 @pytest.fixture
-def default_project(test_db_setup_sessionmaker) -> Project:
-    """An empty private project"""
+def default_organization(test_db_setup_sessionmaker) -> Organization:
+    """Uses session_maker() directly, like default_user/default_project, so
+    it's safe to depend on from fixtures that also use session_maker()
+    without cross-session visibility issues (same DB, autocommitted)."""
+    with session_maker() as db:
+        result = db.execute(select(Organization).where(Organization.id == default_organization_id))
+        org = result.scalars().first()
+        if org is None:
+            new_org = Organization(id=default_organization_id, name=default_organization_name)
+            db.add(new_org)
+            db.commit()
+            db.refresh(new_org)
+            return new_org
+        return org
+
+
+@pytest.fixture
+def other_org_user(session: Session):
+    """A user who is org_admin of a DIFFERENT organization entirely --
+    distinct from make_org_user(role=None), which has no membership at all.
+    This confirms role checks are scoped per-organization, not just
+    'has some role somewhere'."""
+    other_org = Organization(id=uuid4(), name="Other Org")
+    session.add(other_org)
+    session.commit()
+
+    from app.core import security
+
+    user_id = uuid4()
+    user = User(
+        id=user_id,
+        email=f"{user_id}@test.com",
+        hashed_password="x",
+        name="Outsider",
+    )
+    session.add(user)
+    session.commit()
+    session.add(
+        UserOrganization(user_id=user.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN)
+    )
+    session.commit()
+
+    token = security.create_jwt_token(str(user_id), 60 * 60 * 24, refresh=False)[0]
+    headers = {"Authorization": f"Bearer {token}"}
+    return user, headers
+
+
+@pytest.fixture
+def default_user_org_membership(
+    default_user: User, default_organization: Organization
+) -> UserOrganization:
+    """default_user is org_admin of default_organization."""
+    with session_maker() as db:
+        result = db.execute(
+            select(UserOrganization).where(
+                UserOrganization.user_id == default_user.id,
+                UserOrganization.organization_id == default_organization.id,
+            )
+        )
+        membership = result.scalars().first()
+        if membership is None:
+            new_membership = UserOrganization(
+                user_id=default_user.id,
+                organization_id=default_organization.id,
+                role=OrgRole.ORG_ADMIN,
+            )
+            db.add(new_membership)
+            db.commit()
+            db.refresh(new_membership)
+            return new_membership
+        return membership
+
+
+@pytest.fixture
+def make_org_user(default_organization: Organization):
+    """
+    Factory fixture for parametrized role tests.
+    make_org_user(role=OrgRole.RESPO_BENEVOLE) -> (user, headers), member of default_organization.
+    make_org_user(role=None)                   -> authenticated user with NO membership (tests 403 path).
+    make_org_user(global_role=GlobalRole.SUPER_ADMIN) -> bypasses org role checks entirely.
+    Always creates a brand-new user (unique email) so parametrized cases don't collide.
+    """
+
+    def _factory(
+        role: OrgRole | None = None,
+        global_role: GlobalRole = GlobalRole.USER,
+    ) -> tuple[User, dict]:
+        with session_maker() as db:
+            user_id = uuid4()
+            user = User(
+                id=user_id,
+                email=f"{user_id}@test.com",
+                hashed_password=default_user_password_hash,
+                name="Test User",
+                global_role=global_role,
+            )
+            db.add(user)
+            db.commit()
+            db.refresh(user)
+
+            if role is not None:
+                db.add(
+                    UserOrganization(
+                        user_id=user.id, organization_id=default_organization.id, role=role
+                    )
+                )
+                db.commit()
+                db.refresh(user)
+
+        token = security.create_jwt_token(str(user_id), 60 * 60 * 24, refresh=False)[0]
+        headers = {"Authorization": f"Bearer {token}"}
+        return user, headers
+
+    return _factory
+
+
+@pytest.fixture
+def default_project(default_organization: Organization) -> Project:
+    """An empty private project. Depends on default_organization so the FK
+    is always satisfied regardless of which fixtures a test happens to request."""
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         if project is None:
-            new_project = Project(name=default_project_name, is_public=False)
+            new_project = Project(
+                organization_id=default_organization.id,
+                name=default_project_name,
+                is_public=False,
+            )
             new_project.id = default_project_id
             db.add(new_project)
             db.commit()
@@ -103,16 +236,20 @@ def default_project(test_db_setup_sessionmaker) -> Project:
 
 
 @pytest.fixture
-def default_public_project(test_db_setup_sessionmaker) -> Project:
+def default_public_project(default_organization: Organization) -> Project:
     """A public project with 1 volunteer, 1 slot & 1 sms associated to."""
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         if project is None:
-            new_project = Project(name=default_project_name, is_public=True)
+            new_project = Project(
+                organization_id=default_organization.id,
+                name=default_project_name,
+                is_public=True,
+            )
             new_project.id = default_project_id
             db.add(new_project)
-            # Create a volunteer
+
             volunteer = Volunteer(
                 project_id=default_project_id,
                 name="Arthur",
@@ -123,7 +260,7 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             volunteer.id = default_volunteer_id
             db.add(volunteer)
-            # Create a slot
+
             slot = Slot(
                 project_id=default_project_id,
                 title="être roi",
@@ -133,10 +270,11 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             slot.id = default_slot_id
             slot.volunteers.append(volunteer)
             db.add(slot)
+
             tag = SlotTag(project_id=default_project_id, title="Royal")
             tag.id = default_tag_id
             db.add(tag)
-            # Create a sms
+
             sms = Sms(
                 project_id=default_project_id,
                 content="Bonjour sir",
@@ -144,14 +282,12 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             sms.id = default_sms_id
             db.add(sms)
+
             tmp = SlotTemplate(project_id=default_project_id, title="basic template")
             tmp.id = default_template_id
             db.add(tmp)
+
             db.commit()
-            db.commit()
-            db.refresh(sms)
-            db.refresh(slot)
-            db.refresh(volunteer)
             db.refresh(new_project)
             return new_project
         return project

+ 96 - 0
app/tests/shared_access.py

@@ -0,0 +1,96 @@
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from httpx import AsyncClient
+
+from app.main import app
+from app.models import OrgRole
+
+# Type alias for our route definition: (Method, Endpoint Name, Path Params, JSON Payload)
+RouteSpec = tuple[str, str, dict[str, Any], dict[str, Any] | None]
+
+
+class SharedProjectAccessTests:
+    """
+    A reusable interface for testing cross-cutting concerns (Auth, Permissions, 404s)
+    across project-scoped API endpoints.
+
+    HOW TO USE:
+    1. Inherit from this class.
+    2. Apply `@pytest.mark.parametrize("route_spec", YOUR_ROUTES, ids=your_id_formatter)`
+       to your child class.
+    3. Override the `resolved_route` fixture to map static placeholders (e.g., "PROJECT_ID")
+       to actual database fixture objects at execution time.
+    """
+
+    @pytest.fixture
+    def resolved_route(self, *args, **kwargs) -> RouteSpec:
+        """
+        INTERFACE FIXTURE: Child classes MUST override this fixture.
+
+        This fixture is responsible for taking a static `route_spec` and swapping
+        out string placeholders for real UUIDs generated by database fixtures.
+
+        Returns:
+            Tuple containing:
+            - HTTP Method (str)
+            - FastAPI endpoint name (str)
+            - Path parameters (dict)
+            - JSON payload (dict or None)
+        """
+        raise NotImplementedError(
+            "Child classes inheriting from SharedProjectAccessTests must implement "
+            "the `resolved_route` fixture to inject real database IDs."
+        )
+
+    async def test_requires_auth(self, client: AsyncClient, resolved_route: RouteSpec):
+        """Ensures the endpoint returns 401 Unauthorized if no token is provided."""
+        method, endpoint, kwargs, payload = resolved_route
+        url = app.url_path_for(endpoint, **kwargs)
+        req = getattr(client, method.lower())
+
+        response = await req(url, json=payload) if payload else await req(url)
+        assert response.status_code == 401, f"{response.status_code} != 401"
+
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, other_org_user, resolved_route: RouteSpec
+    ):
+        """Ensures a user from a DIFFERENT organization receives a 403 Forbidden."""
+        method, endpoint, kwargs, payload = resolved_route
+        if "project_id" not in kwargs:
+            pytest.skip("Not a project-scoped route. Skipping 403 check.")
+        url = app.url_path_for(endpoint, **kwargs)
+        _, headers = other_org_user
+        req = getattr(client, method.lower())
+
+        response = (
+            await req(url, json=payload, headers=headers)
+            if payload
+            else await req(url, headers=headers)
+        )
+        assert response.status_code == 403, f"{response.status_code} != 403"
+
+    async def test_project_not_found(
+        self, client: AsyncClient, make_org_user, resolved_route: RouteSpec
+    ):
+        """Ensures that if the project does not exist, the API returns a 404."""
+        method, endpoint, kwargs, payload = resolved_route
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+
+        # Inject a non-existent project_id
+        kwargs_copy = kwargs.copy()
+        if "project_id" in kwargs_copy:
+            kwargs_copy["project_id"] = str(uuid4())
+        else:
+            pytest.skip("Not a project-scoped route. Skipping 403 check.")
+
+        url = app.url_path_for(endpoint, **kwargs_copy)
+        req = getattr(client, method.lower())
+
+        response = (
+            await req(url, json=payload, headers=headers)
+            if payload
+            else await req(url, headers=headers)
+        )
+        assert response.status_code == 404

+ 535 - 0
app/tests/test_commissions.py

@@ -0,0 +1,535 @@
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.session import session as session_maker
+from app.main import app
+from app.models import Commission, Organization, OrgRole, Project, User, UserOrganization
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+MANAGE_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE]
+FORBIDDEN_MANAGE_ROLES = [OrgRole.RESPO_COMMISSION]
+READ_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+
+@pytest.fixture
+def two_users() -> tuple[User, User]:
+    with session_maker() as db:
+        u1 = User(
+            email="u1@test.com",
+            hashed_password="hashed1",
+            name="Arthur Pendragon",
+            phone_number="0601010101",
+        )
+        u2 = User(
+            email="u2@test.com",
+            hashed_password="hashed2",
+            name="Merlin Enchanteur",
+            phone_number="0602020202",
+        )
+        db.add_all([u1, u2])
+        db.commit()
+        db.refresh(u1)
+        db.refresh(u2)
+        return u1, u2
+
+
+@pytest.fixture
+def default_commission(default_project: Project) -> Commission:
+    with session_maker() as db:
+        commission = Commission(project_id=default_project.id, name="Logistique")
+        db.add(commission)
+        db.commit()
+        db.refresh(commission)
+        return commission
+
+
+route_kwarg = {"project_id": "PROJECT", "commission_id": "COMMISSION"}
+COMMISSION_ROUTES = [
+    ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
+    ("POST", "create_commission", {"project_id": "PROJECT"}, {"name": "Test"}),
+    ("GET", "get_commission", route_kwarg, None),
+    ("POST", "update_commission", route_kwarg, {"name": "Updated"}),
+    ("DELETE", "delete_commission", route_kwarg, None),
+    ("POST", "add_members_to_commission", route_kwarg, {"user_ids": ["USER"]}),
+    ("DELETE", "remove_member_from_commission", {**route_kwarg, "user_id": "USER"}, None),
+    (
+        "POST",
+        "invite_commission_member",
+        route_kwarg,
+        {"email": "crosscut@test.com", "name": "Cross Cut", "password": "x"},
+    ),
+]
+
+
+class TestCommissionCrossCutting(SharedProjectAccessTests):
+    @pytest.fixture(params=COMMISSION_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project, default_commission):
+        method, endpoint, kwargs, payload = request.param
+        resolved_kwargs = {
+            k: (
+                default_project.id
+                if v == "PROJECT"
+                else default_commission.id
+                if v == "COMMISSION"
+                else v
+            )
+            for k, v in kwargs.items()
+        }
+
+        return method, endpoint, resolved_kwargs, payload
+
+
+class TestListCommissions:
+    @pytest.mark.parametrize("role", READ_ROLES)
+    async def test_role_access(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_commissions", project_id=default_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["name"] == "Logistique"
+
+
+class TestCreateCommission:
+    @pytest.mark.parametrize("role", MANAGE_ROLES)
+    async def test_role_access(
+        self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_commission", project_id=default_project.id),
+            headers=headers,
+            json={"name": "Accueil"},
+        )
+        assert response.status_code == 200
+        commission = (
+            session.execute(select(Commission).where(Commission.name == "Accueil"))
+            .scalars()
+            .first()
+        )
+        assert commission is not None
+        assert commission.project_id == str(default_project.id)
+
+    @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
+    async def test_forbidden_manage_roles(
+        self, client: AsyncClient, default_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_commission", project_id=default_project.id),
+            headers=headers,
+            json={"name": "Nope"},
+        )
+        assert response.status_code == 403
+
+
+class TestGetCommission:
+    async def test_commission_not_found(
+        self, client: AsyncClient, make_org_user, default_project: Project
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for(
+                "get_commission", project_id=default_project.id, commission_id=uuid.uuid4()
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 404
+
+    async def test_commission_from_other_project_not_found(
+        self, client: AsyncClient, default_commission: Commission, make_org_user, session: Session
+    ):
+        other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
+        session.add(other_org)
+        session.commit()
+        other_project = Project(name="Other Project", is_public=False, organization_id=other_org.id)
+        session.add(other_project)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for(
+                "get_commission", project_id=other_project.id, commission_id=default_commission.id
+            ),
+            headers=headers,
+        )
+        # Even if they have the org admin role, the project_id URL mismatch means 403 or 404
+        assert response.status_code in (403, 404)
+
+
+class TestUpdateCommission:
+    async def test_updates_name(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "update_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"name": "Securite Renamed"},
+        )
+        assert response.status_code == 200
+        commission = session.get(Commission, default_commission.id)
+        assert commission.name == "Securite Renamed"
+
+
+class TestDeleteCommission:
+    async def test_deletes_commission_and_members_links_but_not_users(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, _ = two_users
+        commission = session.get(Commission, default_commission.id)
+        commission.members.append(u1)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+        )
+
+        assert response.status_code == 200
+        assert session.get(Commission, default_commission.id) is None
+        # User should still exist
+        assert session.get(User, u1.id) is not None
+
+
+class TestCommissionMembership:
+    async def test_add_users_to_commission(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+    ):
+        u1, u2 = two_users
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "add_members_to_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"user_ids": [str(u1.id), str(u2.id)]},
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["members_id"]) == 2
+        assert str(u1.id) in data["members_id"]
+        assert str(u2.id) in data["members_id"]
+
+    async def test_add_is_idempotent(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+    ):
+        u1, _ = two_users
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+
+        await client.post(
+            app.url_path_for(
+                "add_members_to_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"user_ids": [str(u1.id)]},
+        )
+
+        response = await client.post(
+            app.url_path_for(
+                "add_members_to_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"user_ids": [str(u1.id)]},
+        )
+        assert response.status_code == 200
+        assert len(response.json()["members_id"]) == 1
+
+    async def test_remove_member_from_commission(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, u2 = two_users
+        commission = session.get(Commission, default_commission.id)
+        assert commission is not None
+        commission.members.append(u1)
+        commission.members.append(u2)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.delete(
+            app.url_path_for(
+                "remove_member_from_commission",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+                user_id=u1.id,
+            ),
+            headers=headers,
+        )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["members_id"]) == 1
+        assert data["members_id"][0] == str(u2.id)
+
+
+class TestInviteCommissionMember:
+    @pytest.mark.parametrize("role", MANAGE_ROLES)
+    async def test_creates_account_and_adds_to_commission(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "lancelot@test.com",
+                "name": "Lancelot du Lac",
+                "password": "TempPass123!",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        user = session.scalar(select(User).where(User.email == "lancelot@test.com"))
+        assert user is not None
+        assert user.name == "Lancelot du Lac"
+        assert user.must_change_password is True
+        assert str(user.id) in data["members_id"]
+
+        membership = session.get(UserOrganization, (user.id, default_project.organization_id))
+        assert membership is not None
+        assert membership.role == OrgRole.RESPO_COMMISSION
+
+    @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
+    async def test_forbidden_manage_roles(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"email": "nope@test.com", "name": "Nope", "password": "x"},
+        )
+        assert response.status_code == 403
+
+    async def test_existing_user_not_recreated_and_password_untouched(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, _ = two_users
+        original_hash = u1.hashed_password
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": u1.email,
+                "name": "Ignored Name Change",
+                "password": "AttackerChosenPassword!",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+
+        refreshed = session.get(User, u1.id)
+        assert refreshed.hashed_password == original_hash  # untouched
+        assert refreshed.name == "Arthur Pendragon"  # untouched
+        assert refreshed.must_change_password is False  # never set on existing account
+        assert str(u1.id) in response.json()["members_id"]
+
+    async def test_existing_org_membership_role_not_overwritten(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, _ = two_users
+        session.add(
+            UserOrganization(
+                user_id=u1.id,
+                organization_id=default_project.organization_id,
+                role=OrgRole.RESPO_BENEVOLE,
+            )
+        )
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            # requesting respo_commission, but they're already respo_benevole -- must not downgrade
+            json={
+                "email": u1.email,
+                "name": u1.name,
+                "password": "x",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+
+        membership = session.get(UserOrganization, (u1.id, default_project.organization_id))
+        assert membership.role == OrgRole.RESPO_BENEVOLE  # unchanged
+
+    async def test_respo_benevole_cannot_grant_org_admin(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "wouldbe@test.com",
+                "name": "Would Be Admin",
+                "password": "x",
+                "org_role": "org_admin",
+            },
+        )
+        assert response.status_code == 403
+
+    async def test_org_admin_can_grant_org_admin(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "newadmin@test.com",
+                "name": "New Admin",
+                "password": "x",
+                "org_role": "org_admin",
+            },
+        )
+        assert response.status_code == 200
+        user = session.scalar(select(User).where(User.email == "newadmin@test.com"))
+        membership = session.get(UserOrganization, (user.id, default_project.organization_id))
+        assert membership.role == OrgRole.ORG_ADMIN
+
+    async def test_invite_is_idempotent_on_commission_membership(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {
+            "email": "guenievre@test.com",
+            "name": "Guenièvre",
+            "password": "x",
+            "org_role": "respo_commission",
+        }
+        for _ in range(2):
+            response = await client.post(
+                app.url_path_for(
+                    "invite_commission_member",
+                    project_id=default_project.id,
+                    commission_id=default_commission.id,
+                ),
+                headers=headers,
+                json=payload,
+            )
+            assert response.status_code == 200
+        assert len(response.json()["members_id"]) == 1

+ 527 - 0
app/tests/test_organizations.py

@@ -0,0 +1,527 @@
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core import security
+from app.main import app
+from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
+from app.tests.conftest import default_organization_id, default_organization_name
+
+pytestmark = pytest.mark.asyncio
+
+ALL_ORG_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+
+class TestListOrganizations:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.get(app.url_path_for("list_organizations"))
+        assert response.status_code == 401
+
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES)
+    async def test_member_sees_own_org(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(app.url_path_for("list_organizations"), headers=headers)
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["id"] == default_organization_id
+
+    async def test_non_member_sees_nothing(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.get(app.url_path_for("list_organizations"), headers=headers)
+        assert response.status_code == 200
+        assert response.json() == []
+
+    async def test_super_admin_sees_all_orgs(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
+        session.add(other_org)
+        session.commit()
+
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(app.url_path_for("list_organizations"), headers=headers)
+        assert response.status_code == 200
+        ids = [o["id"] for o in response.json()]
+        assert default_organization_id in ids
+        assert other_org.id in ids
+
+
+class TestCreateOrganization:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.post(
+            app.url_path_for("create_organization"), json={"name": "New Org"}
+        )
+        assert response.status_code == 401
+
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
+    async def test_non_super_admin_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_organization"), headers=headers, json={"name": "New Org"}
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_creates_organization(
+        self, client: AsyncClient, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_organization"), headers=headers, json={"name": "New Org"}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "New Org"
+
+        org = (
+            session.execute(select(Organization).where(Organization.name == "New Org"))
+            .scalars()
+            .first()
+        )
+        assert org is not None
+
+    async def test_duplicate_name_rejected(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_organization"),
+            headers=headers,
+            json={"name": default_organization_name},
+        )
+        assert response.status_code == 400
+
+    async def test_missing_name_validation_error(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_organization"), headers=headers, json={}
+        )
+        assert response.status_code == 422
+
+
+class TestGetOrganization:
+    async def test_requires_auth(self, client: AsyncClient, default_organization: Organization):
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=default_organization_id)
+        )
+        assert response.status_code == 401
+
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES)
+    async def test_member_can_view(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] == default_organization_id
+
+    async def test_non_member_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_bypasses_membership(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    async def test_includes_memberships(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        memberships = response.json()["memberships"]
+        assert len(memberships) == 1
+        assert memberships[0]["role"] == OrgRole.RESPO_BENEVOLE.value
+
+    async def test_not_found(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(
+            app.url_path_for("get_organization", organization_id=uuid.uuid4()), headers=headers
+        )
+        assert response.status_code == 404
+
+
+class TestUpdateOrganization:
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
+    async def test_non_super_admin_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("update_organization", organization_id=default_organization_id),
+            headers=headers,
+            json={"name": "Renamed"},
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_renames(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("update_organization", organization_id=default_organization_id),
+            headers=headers,
+            json={"name": "Renamed"},
+        )
+        assert response.status_code == 200
+        org = session.get(Organization, default_organization_id)
+        assert org.name == "Renamed"
+
+    async def test_not_found(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("update_organization", organization_id=uuid.uuid4()),
+            headers=headers,
+            json={"name": "Nope"},
+        )
+        assert response.status_code == 404
+
+
+class TestDeleteOrganization:
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
+    async def test_non_super_admin_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.delete(
+            app.url_path_for("delete_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_deletes_and_cascades_projects(
+        self, client: AsyncClient, default_project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_organization", organization_id=default_organization_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Organization, default_organization_id) is None
+
+        from app.models import Project
+
+        assert session.get(Project, default_project.id) is None
+
+    async def test_not_found(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_organization", organization_id=uuid.uuid4()), headers=headers
+        )
+        assert response.status_code == 404
+
+
+class TestAddMember:
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
+    async def test_non_super_admin_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=default_organization_id),
+            headers=headers,
+            json={"user_id": str(uuid.uuid4()), "role": OrgRole.RESPO_BENEVOLE.value},
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_adds_existing_user(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        target_user, _ = make_org_user(role=None)  # exists, no membership yet
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=default_organization_id),
+            headers=admin_headers,
+            json={"user_id": target_user.id, "role": OrgRole.RESPO_COMMISSION.value},
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["role"] == OrgRole.RESPO_COMMISSION.value
+        assert data["user"]["email"] == target_user.email
+
+        membership = session.get(UserOrganization, (target_user.id, default_organization_id))
+        assert membership is not None
+        assert membership.role == OrgRole.RESPO_COMMISSION
+
+    async def test_user_can_belong_to_multiple_orgs(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        """Confirms the multi-org membership model -- no unique(user_id) constraint."""
+        target_user, _ = make_org_user(role=OrgRole.ORG_ADMIN)  # already in default_organization
+        other_org = Organization(id=uuid.uuid4(), name="Second Org")
+        session.add(other_org)
+        session.commit()
+
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=other_org.id),
+            headers=admin_headers,
+            json={"user_id": target_user.id, "role": OrgRole.RESPO_BENEVOLE.value},
+        )
+        assert response.status_code == 200
+
+        memberships = (
+            session.execute(
+                select(UserOrganization).where(UserOrganization.user_id == target_user.id)
+            )
+            .scalars()
+            .all()
+        )
+        assert len(memberships) == 2
+
+    async def test_unknown_user_id_not_found(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=default_organization_id),
+            headers=admin_headers,
+            json={"user_id": str(uuid.uuid4()), "role": OrgRole.RESPO_BENEVOLE.value},
+        )
+        assert response.status_code == 404
+
+    async def test_duplicate_membership_rejected(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)  # already a member
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=default_organization_id),
+            headers=admin_headers,
+            json={"user_id": target_user.id, "role": OrgRole.ORG_ADMIN.value},
+        )
+        assert response.status_code == 400
+
+    async def test_organization_not_found(self, client: AsyncClient, make_org_user):
+        target_user, _ = make_org_user(role=None)
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.post(
+            app.url_path_for("add_member", organization_id=uuid.uuid4()),
+            headers=admin_headers,
+            json={"user_id": target_user.id, "role": OrgRole.RESPO_BENEVOLE.value},
+        )
+        assert response.status_code == 404
+
+
+class TestUpdateMemberRole:
+    @pytest.mark.parametrize("role", [OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION, None])
+    async def test_insufficient_role_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
+            ),
+            headers=headers,
+            json={"role": OrgRole.ORG_ADMIN.value},
+        )
+        assert response.status_code == 403
+
+    async def test_org_admin_of_same_org_can_change_role(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
+        _, org_admin_headers = make_org_user(role=OrgRole.ORG_ADMIN)
+
+        response = await client.post(
+            app.url_path_for(
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
+            ),
+            headers=org_admin_headers,
+            json={"role": OrgRole.RESPO_BENEVOLE.value},
+        )
+        assert response.status_code == 200
+        assert response.json()["role"] == OrgRole.RESPO_BENEVOLE.value
+
+        membership = session.get(UserOrganization, (target_user.id, default_organization_id))
+        assert membership.role == OrgRole.RESPO_BENEVOLE
+
+    async def test_org_admin_of_different_org_forbidden(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        """An org_admin has no authority over an organization they don't belong to."""
+        other_org = Organization(id=uuid.uuid4(), name="Other Org")
+        session.add(other_org)
+        session.commit()
+
+        target_user, _ = make_org_user(
+            role=OrgRole.RESPO_COMMISSION
+        )  # member of default_organization
+
+        # org_admin, but of other_org, not default_organization
+        outsider = User(
+            id=uuid.uuid4(), email="outsider@test.com", hashed_password="x", name="Outsider"
+        )
+        session.add(outsider)
+        session.commit()
+        session.add(
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN
+            )
+        )
+        session.commit()
+        outsider_token = security.create_jwt_token(str(outsider.id), 60 * 60 * 24, refresh=False)[0]
+        outsider_headers = {"Authorization": f"Bearer {outsider_token}"}
+
+        response = await client.post(
+            app.url_path_for(
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
+            ),
+            headers=outsider_headers,
+            json={"role": OrgRole.ORG_ADMIN.value},
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_changes_role(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.post(
+            app.url_path_for(
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
+            ),
+            headers=admin_headers,
+            json={"role": OrgRole.ORG_ADMIN.value},
+        )
+        assert response.status_code == 200
+        assert response.json()["role"] == OrgRole.ORG_ADMIN.value
+
+        membership = session.get(UserOrganization, (target_user.id, default_organization_id))
+        assert membership.role == OrgRole.ORG_ADMIN
+
+    async def test_membership_not_found(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        non_member, _ = make_org_user(role=None)
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.post(
+            app.url_path_for(
+                "update_member_role", organization_id=default_organization_id, user_id=non_member.id
+            ),
+            headers=admin_headers,
+            json={"role": OrgRole.ORG_ADMIN.value},
+        )
+        assert response.status_code == 404
+
+
+class TestRemoveMember:
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
+    async def test_non_super_admin_forbidden(
+        self, client: AsyncClient, default_organization: Organization, make_org_user, role
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        _, headers = make_org_user(role=role)
+        response = await client.delete(
+            app.url_path_for(
+                "remove_member", organization_id=default_organization_id, user_id=target_user.id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_super_admin_removes_member(
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
+    ):
+        target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.delete(
+            app.url_path_for(
+                "remove_member", organization_id=default_organization_id, user_id=target_user.id
+            ),
+            headers=admin_headers,
+        )
+        assert response.status_code == 200
+        assert session.get(UserOrganization, (target_user.id, default_organization_id)) is None
+        # user account itself is untouched
+        assert session.get(User, target_user.id) is not None
+
+    async def test_membership_not_found(
+        self, client: AsyncClient, default_organization: Organization, make_org_user
+    ):
+        non_member, _ = make_org_user(role=None)
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+
+        response = await client.delete(
+            app.url_path_for(
+                "remove_member", organization_id=default_organization_id, user_id=non_member.id
+            ),
+            headers=admin_headers,
+        )
+        assert response.status_code == 404

+ 184 - 0
app/tests/test_password_reset.py

@@ -0,0 +1,184 @@
+import hashlib
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.core.security import get_password_hash, verify_password
+from app.core.session import session as session_maker
+from app.models import PasswordResetToken, User
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+def existing_user() -> User:
+    with session_maker() as db:
+        user = User(
+            email="perceval@test.com",
+            hashed_password=get_password_hash("OldPassword123"),
+            name="Perceval",
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
+        return user
+
+
+def make_token(
+    session: Session, user: User, *, purpose="reset", expires_in=timedelta(hours=1), used=False
+):
+    raw = "raw-token-" + uuid.uuid4().hex
+    token_hash = hashlib.sha256(raw.encode()).hexdigest()
+    record = PasswordResetToken(
+        user_id=user.id,
+        token_hash=token_hash,
+        expires_at=datetime.now(UTC) + expires_in,
+        purpose=purpose,
+        used_at=datetime.now(UTC) if used else None,
+    )
+    session.add(record)
+    session.commit()
+    return raw
+
+
+class TestForgotPassword:
+    async def test_existing_email_returns_204_and_creates_token(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        response = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        assert response.status_code == 204
+        token = session.scalar(
+            select(PasswordResetToken).where(PasswordResetToken.user_id == existing_user.id)
+        )
+        assert token is not None
+        assert token.purpose == "reset"
+
+    async def test_unknown_email_also_returns_204_no_enumeration(self, client: AsyncClient):
+        response = await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert response.status_code == 204
+
+    async def test_unknown_email_creates_no_token(self, client: AsyncClient, session: Session):
+        await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert session.scalar(select(PasswordResetToken)) is None
+
+    async def test_response_body_identical_for_both_cases(
+        self, client: AsyncClient, existing_user: User
+    ):
+        # guards against a future refactor leaking a distinguishable signal
+        r1 = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        r2 = await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert r1.status_code == r2.status_code == 204
+        assert r1.content == r2.content == b""
+
+
+class TestSetPasswordWithToken:
+    async def test_valid_token_sets_password(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+        refreshed = session.get(User, existing_user.id)
+        assert verify_password("NewSecurePass456", refreshed.hashed_password)
+
+    async def test_valid_token_clears_must_change_password(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        existing_user.must_change_password = True
+        session.merge(existing_user)
+        session.commit()
+        raw_token = make_token(session, existing_user)
+
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+        assert session.get(User, existing_user.id).must_change_password is False
+
+    async def test_token_marked_used_after_success(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
+        record = session.scalar(
+            select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
+        )
+        assert record.used_at is not None
+
+    async def test_token_cannot_be_reused(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "First12345"}
+        )
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "Second67890"}
+        )
+        assert response.status_code == 400
+
+    async def test_expired_token_rejected(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user, expires_in=timedelta(hours=-1))
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 400
+
+    async def test_already_used_token_rejected(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user, used=True)
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 400
+
+    async def test_unknown_token_rejected(self, client: AsyncClient):
+        response = await client.post(
+            "/users/set-password",
+            json={"token": "not-a-real-token", "password": "NewSecurePass456"},
+        )
+        assert response.status_code == 400
+
+    async def test_invite_purpose_token_also_works(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(
+            session, existing_user, purpose="invite", expires_in=timedelta(days=7)
+        )
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+
+
+class TestForgotPasswordRateLimit:
+    async def test_exceeding_limit_stops_sending_but_still_204s(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        for _ in range(3):
+            await client.post("/users/forgot-password", json={"email": existing_user.email})
+        tokens_before = session.scalar(
+            select(func.count())
+            .select_from(PasswordResetToken)
+            .where(PasswordResetToken.user_id == existing_user.id)
+        )
+        response = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        tokens_after = session.scalar(
+            select(func.count())
+            .select_from(PasswordResetToken)
+            .where(PasswordResetToken.user_id == existing_user.id)
+        )
+        assert response.status_code == 204
+        assert tokens_after == tokens_before

+ 0 - 240
app/tests/test_project.py

@@ -1,240 +0,0 @@
-import uuid
-from httpx import AsyncClient
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.main import app
-from app.models import Project, Slot, Volunteer
-from app.tests.conftest import default_project_id, default_project_name
-
-
-async def test_read_list_project(
-    client: AsyncClient, default_user_headers: dict, default_project: Project
-):
-    response = await client.get(app.url_path_for("list_project"), headers=default_user_headers)
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-    project_response = data[0]
-    assert project_response["id"] == default_project_id
-    assert "created_at" in project_response
-
-
-async def test_read_list_project_without_user(client: AsyncClient):
-    response = await client.get(app.url_path_for("list_project"))
-    assert response.status_code == 401
-
-
-async def test_read_list_public_project_without_user(client: AsyncClient, default_project: Project):
-    response = await client.get(app.url_path_for("list_public_project"))
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 0
-
-
-async def test_read_list_public_project_without_user_1(
-    client: AsyncClient, default_public_project: Project
-):
-    response = await client.get(app.url_path_for("list_public_project"))
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-
-
-async def test_get_project_without_user(client: AsyncClient, default_project: Project):
-    response = await client.get(
-        app.url_path_for("get_public_project", project_id=default_project_id)
-    )
-    assert response.status_code == 404
-
-
-async def test_get_public_project(client: AsyncClient, default_public_project: Project):
-    response = await client.get(
-        app.url_path_for("get_public_project", project_id=default_project_id)
-    )
-    assert response.status_code == 200
-    assert response.json()["id"] == default_project_id
-    assert response.json()["name"] == default_project_name
-
-
-async def test_get_project(
-    client: AsyncClient, default_public_project: Project, default_user_headers: dict
-):
-    # Test without autentication
-    response = await client.get(app.url_path_for("get_project", project_id=default_project_id))
-    assert response.status_code == 401
-
-    response = await client.get(
-        app.url_path_for("get_project", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["id"] == default_project_id
-    assert response.json()["name"] == default_project_name
-
-
-async def test_create_project_without_user(client: AsyncClient, session: Session):
-    response = await client.post(
-        app.url_path_for("create_project"),
-        json={"name": "Coucou"},
-    )
-    assert response.status_code == 401
-    # Verify no project were added
-    result = session.execute(select(Project))
-    project = result.scalars().first()
-    assert project is None
-
-
-async def test_create_project(client: AsyncClient, default_user_headers: dict, session: Session):
-    # Create a private project
-    response = await client.post(
-        app.url_path_for("create_project"),
-        headers=default_user_headers,
-        json={"name": "Coucou"},
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Project).where(Project.name == "Coucou"))
-    project = result.scalars().first()
-    assert project is not None
-    assert not project.is_public
-
-    # Create a public project
-    response = await client.post(
-        app.url_path_for("create_project"),
-        headers=default_user_headers,
-        json={"name": "Public", "is_public": True},
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Project).where(Project.name == "Public"))
-    project = result.scalars().first()
-    assert project is not None
-    assert project.is_public
-
-
-async def test_create_project_fail(
-    client: AsyncClient, default_user_headers: dict, session: Session
-):
-    # Create a project with a bad
-    response = await client.post(
-        app.url_path_for("create_project"),
-        headers=default_user_headers,
-        json={"coucou": "Coucou"},
-    )
-    assert response.status_code == 422
-    assert "detail" in response.json()
-    result = session.execute(select(Project))
-    project = result.scalars().first()
-    assert project is None
-
-
-async def test_update_project(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Update a public project
-    response = await client.post(
-        app.url_path_for("update_project", project_id=default_project_id),
-        headers=default_user_headers,
-        json={"name": "Coucou"},
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Project).where(Project.id == default_project_id))
-    project = result.scalars().first()
-    assert project is not None
-    assert project.name == "Coucou"
-
-    # Update a public project
-    response = await client.post(
-        app.url_path_for("update_project", project_id=default_project_id),
-        headers=default_user_headers,
-        json={"name": "Coucou 2", "is_public": False},
-    )
-    assert response.status_code == 200
-    session.refresh(project)
-    assert project is not None
-    assert project.name == "Coucou 2"
-    assert project.created_at < project.updated_at
-    assert not project.is_public
-
-
-async def test_update_project_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail updating the project due to not logged in
-    response = await client.post(
-        app.url_path_for("update_project", project_id=default_project_id),
-        json={"name": "Coucou 2"},
-    )
-    assert response.status_code == 401
-    result = session.execute(select(Project).where(Project.id == default_project_id))
-    project = result.scalars().first()
-    assert project is not None
-    assert project.name == default_project_name
-
-    # Update is_public without name => Validation error
-    response = await client.post(
-        app.url_path_for("update_project", project_id=default_project_id),
-        headers=default_user_headers,
-        json={"is_public": False},
-    )
-    assert response.status_code == 422
-
-
-async def test_delete_project(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail deleting the project due to not logged in
-    response = await client.delete(
-        app.url_path_for("delete_project", project_id=default_project_id)
-    )
-    assert response.status_code == 401
-    result = session.execute(select(Project).where(Project.id == default_project_id))
-    project = result.scalars().first()
-    assert project is not None
-
-    # Proper deletion
-    response = await client.delete(
-        app.url_path_for("delete_project", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Project).where(Project.id == default_project_id))
-    project = result.scalars().first()
-    assert project is None
-    # check deletion is cascaded to volunteers
-    result = session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
-    volunteer = result.scalars().first()
-    assert volunteer is None
-    # check deletion is cascaded to slots
-    result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
-    slot = result.scalars().first()
-    assert slot is None
-
-    # Idempotence test
-    response = await client.delete(
-        app.url_path_for("delete_project", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    # can delete random uuid
-    response = await client.delete(
-        app.url_path_for("delete_project", project_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    # Cannot delete non uuid string
-    response = await client.delete(
-        app.url_path_for("delete_project", project_id="not uidstr"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422

+ 309 - 0
app/tests/test_projects.py

@@ -0,0 +1,309 @@
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import GlobalRole, OrgRole, Project, Slot, Volunteer
+from app.tests.conftest import default_organization_id, default_project_id, default_project_name
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+ALL_ORG_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+PROJECT_ROUTES = [
+    ("GET", "list_project", {}, None),
+    (
+        "POST",
+        "create_project",
+        {},
+        {
+            "name": "Test",
+            "organization_id": default_organization_id,
+            "is_public": False,
+        },
+    ),
+    ("POST", "update_project", {"project_id": default_project_id}, {"title": "Updated"}),
+    ("POST", "create_sms_batch", {"project_id": default_project_id}, {"template": "Hello"}),
+    ("DELETE", "delete_project", {"project_id": default_project_id}, None),
+]
+
+
+class TestProjectCrossCutting(SharedProjectAccessTests):
+    @pytest.fixture(params=PROJECT_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+        return request.param
+
+
+class TestListProject:
+    @pytest.mark.parametrize("role", ALL_ORG_ROLES)
+    async def test_org_member_sees_their_project(
+        self, client: AsyncClient, default_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(app.url_path_for("list_project"), headers=headers)
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["id"] == default_project_id
+
+    async def test_super_admin_sees_all_orgs(
+        self, client: AsyncClient, default_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(app.url_path_for("list_project"), headers=headers)
+        assert response.status_code == 200
+        assert len(response.json()) == 1
+
+    async def test_excludes_other_orgs_project(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        make_org_user,
+        session: Session,
+        other_org_user,
+    ):
+        _, headers = other_org_user
+        response = await client.get(app.url_path_for("list_project"), headers=headers)
+        assert response.status_code == 200
+        assert response.json() == []
+
+
+class TestGetProject:
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 200),
+            (OrgRole.RESPO_COMMISSION, 200),
+            (None, 403),
+        ],
+    )
+    async def test_role_access(
+        self, client: AsyncClient, default_project: Project, make_org_user, role, expected_status
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("get_project", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == expected_status
+        if expected_status == 200:
+            assert response.json()["id"] == default_project_id
+
+    @pytest.mark.parametrize("project_id,status", ((default_project_id, 200), (uuid.uuid4(), 404)))
+    async def test_super_admin_bypasses_membership(
+        self, client: AsyncClient, default_project: Project, make_org_user, project_id, status
+    ):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(
+            app.url_path_for("get_project", project_id=project_id), headers=headers
+        )
+        assert response.status_code == status
+
+
+class TestGetPublicProject:
+    async def test_missing_or_private_returns_404(
+        self, client: AsyncClient, default_project: Project
+    ):
+        response = await client.get(
+            app.url_path_for("get_public_project", project_id=default_project_id)
+        )
+        assert response.status_code == 404
+
+    async def test_public_project_no_auth_needed(
+        self, client: AsyncClient, default_public_project: Project
+    ):
+        response = await client.get(
+            app.url_path_for("get_public_project", project_id=default_project_id)
+        )
+        assert response.status_code == 200
+        assert response.json()["name"] == default_project_name
+
+
+class TestListPublicProject:
+    async def test_excludes_private(self, client: AsyncClient, default_project: Project):
+        response = await client.get(app.url_path_for("list_public_project"))
+        assert response.status_code == 200
+        assert response.json() == []
+
+    async def test_includes_public(self, client: AsyncClient, default_public_project: Project):
+        response = await client.get(app.url_path_for("list_public_project"))
+        assert response.status_code == 200
+        assert len(response.json()) == 1
+
+
+class TestCreateProject:
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 403),
+            (OrgRole.RESPO_COMMISSION, 403),
+            (None, 403),
+        ],
+    )
+    async def test_role_access(self, client: AsyncClient, make_org_user, role, expected_status):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_project"),
+            headers=headers,
+            json={"name": f"Project-{role}", "organization_id": default_organization_id},
+        )
+        assert response.status_code == expected_status
+
+    async def test_super_admin_can_create_anywhere(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_project"),
+            headers=headers,
+            json={"name": "Super Created", "organization_id": default_organization_id},
+        )
+        assert response.status_code == 200
+
+    async def test_public_flag(self, client: AsyncClient, make_org_user, session: Session):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_project"),
+            headers=headers,
+            json={"name": "Public", "is_public": True, "organization_id": default_organization_id},
+        )
+        print(response.json())
+        assert response.status_code == 200
+        project = session.execute(select(Project).where(Project.name == "Public")).scalars().first()
+        assert project.is_public
+
+    async def test_validation_error(self, client: AsyncClient, make_org_user, session: Session):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_project"), headers=headers, json={"coucou": "Coucou"}
+        )
+        assert response.status_code == 422
+        assert session.execute(select(Project)).scalars().first() is None
+
+    async def test_existing_name(
+        self, client: AsyncClient, make_org_user, default_project: Project, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_project"),
+            headers=headers,
+            json={"name": default_project_name, "organization_id": default_organization_id},
+        )
+        assert response.status_code == 400
+
+
+class TestUpdateProject:
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 403),
+            (OrgRole.RESPO_COMMISSION, 403),
+            (None, 403),
+        ],
+    )
+    async def test_role_access(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+        expected_status,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("update_project", project_id=default_project_id),
+            headers=headers,
+            json={"name": "Coucou"},
+        )
+        print(response.json())
+        assert response.status_code == expected_status
+        if expected_status == 200:
+            project = session.get(Project, default_project_id)
+            assert project.name == "Coucou"
+
+    async def test_validation_error(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("update_project", project_id=default_project_id),
+            headers=headers,
+            json={"is_public": False},
+        )
+        assert response.status_code == 422
+
+    async def test_updates_timestamps(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        project = session.get(Project, default_project_id)
+        created_at = project.created_at
+
+        response = await client.post(
+            app.url_path_for("update_project", project_id=default_project_id),
+            headers=headers,
+            json={"name": "Coucou 2", "is_public": False},
+        )
+        assert response.status_code == 200
+        session.refresh(project)
+        assert project.name == "Coucou 2"
+        assert project.created_at == created_at
+        assert project.created_at < project.updated_at
+        assert not project.is_public
+
+
+class TestDeleteProject:
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 403),
+            (OrgRole.RESPO_COMMISSION, 403),
+            (None, 403),
+        ],
+    )
+    async def test_role_access(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+        expected_status,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.delete(
+            app.url_path_for("delete_project", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == expected_status
+        project = session.get(Project, default_project_id)
+        if expected_status == 200:
+            assert project is None
+        else:
+            assert project is not None
+
+    async def test_cascades_to_volunteers_and_slots(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_project", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == 200
+        assert (
+            session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
+            .scalars()
+            .first()
+            is None
+        )
+        assert (
+            session.execute(select(Slot).where(Slot.project_id == default_project_id))
+            .scalars()
+            .first()
+            is None
+        )

+ 86 - 0
app/tests/test_projects_stream.py

@@ -0,0 +1,86 @@
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+# Adjust this import to match where you instantiated your broadcast object!
+from app.main import app
+from app.models import OrgRole, Project
+from app.tests.conftest import default_project_id
+
+pytestmark = pytest.mark.asyncio
+
+
+class TestProjectStream:
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 200),
+            (OrgRole.RESPO_COMMISSION, 200),
+            (None, 403),
+        ],
+    )
+    async def test_role_access(
+        self, client: AsyncClient, default_project: Project, make_org_user, role, expected_status
+    ):
+        """Test that only authorized roles can connect to the stream."""
+        _, headers = make_org_user(role=role)
+        url = app.url_path_for("project_stream", project_id=default_project_id)
+
+        # We mock 'broadcast.subscribe' so it immediately returns an empty async iterator
+        # This prevents the endpoint from hanging forever in the test
+        with patch("app.core.events.broadcast.subscribe") as mock_subscribe:
+            # Create an async generator that yields nothing and closes
+            async def mock_generator():
+                return
+                yield
+
+            mock_subscriber = AsyncMock()
+            mock_subscriber.__aenter__.return_value = mock_generator()
+            mock_subscribe.return_value = mock_subscriber
+
+            async with client.stream(
+                "GET", f"{url}?client_id=test-auth", headers=headers
+            ) as response:
+                assert response.status_code == expected_status
+
+    async def test_receives_sse_events(
+        self, client: AsyncClient, default_project: Project, make_org_user
+    ):
+        """Test that events from the broadcaster are correctly formatted as SSE."""
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("project_stream", project_id=default_project_id)
+
+        # Create a fake Redis event
+        class FakeEvent:
+            def __init__(self):
+                self.message = json.dumps(
+                    {
+                        "client_id": "someone-else",
+                        "event": "slot_updated",
+                        "item": {"id": "123", "title": "Installation"},
+                    }
+                )
+
+        with patch("app.core.events.broadcast.subscribe") as mock_subscribe:
+            # Make the mocked subscribe yield our FakeEvent, then stop
+            async def mock_generator():
+                yield FakeEvent()
+
+            mock_subscriber = AsyncMock()
+            mock_subscriber.__aenter__.return_value = mock_generator()
+            mock_subscribe.return_value = mock_subscriber
+
+            async with client.stream(
+                "GET", f"{url}?client_id=listener-client", headers=headers
+            ) as response:
+                assert response.status_code == 200
+
+                # Read the response
+                lines = [line async for line in response.aiter_lines() if line.strip()]
+
+                # Verify the formatting is standard SSE format
+                assert lines[0] == "event: slot_updated"
+                assert "Installation" in lines[1]

+ 0 - 425
app/tests/test_slot.py

@@ -1,425 +0,0 @@
-from datetime import datetime, timedelta, timezone
-import uuid
-from httpx import AsyncClient
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.main import app
-from app.models import Project, Slot, SlotTemplate, Volunteer
-from app.tests.conftest import (
-    default_project_id,
-    default_slot_id,
-    default_volunteer_id,
-    default_template_id,
-)
-
-
-async def test_read_list_project_slots(
-    client: AsyncClient, default_user_headers: dict, default_public_project: Project
-):
-    response = await client.get(
-        app.url_path_for("list_project_slots", project_id=default_project_id),
-    )
-    assert response.status_code == 401
-    response = await client.get(
-        app.url_path_for("list_project_slots", project_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-    response = await client.get(
-        app.url_path_for("list_project_slots", project_id="pas un uuid valid"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    response = await client.get(
-        app.url_path_for("list_project_slots", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-    slot_response = data[0]
-    assert slot_response["title"] == "être roi"
-    assert slot_response["id"] == default_slot_id
-    assert "created_at" in slot_response
-
-
-async def test_create_slot(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(app.url_path_for("create_slot", project_id=default_project_id))
-    assert response.status_code == 401
-    starting_time = datetime(1900, 1, 1)
-    payload = {
-        "title": "être mort",
-        "starting_time": starting_time.isoformat(),
-        "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
-    }
-    # Test invalid project_id
-    response = await client.post(
-        app.url_path_for("create_slot", project_id=uuid.uuid4()),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-    response = await client.post(
-        app.url_path_for("create_slot", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["id"] != default_slot_id
-    assert response.json()["title"] == "être mort"
-    assert response.json()["required_volunteers"] == 0
-    result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
-    slots = result.scalars().all()
-    assert len(slots) > 1
-    slot = [s for s in slots if s.id != default_slot_id][0]
-    assert slot.title == "être mort"
-    assert slot.required_volunteers == 0
-    assert abs(slot.starting_time - starting_time.replace(tzinfo=timezone.utc)) < timedelta(
-        minutes=30
-    )
-
-    # Test invalid payload
-    del payload["title"]
-    response = await client.post(
-        app.url_path_for("create_slot", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_create_slot_min_volunteer(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    starting_time = datetime(1900, 1, 1)
-    payload = {
-        "title": "être mort",
-        "starting_time": starting_time.isoformat(),
-        "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
-        "required_volunteers": 2,
-    }
-    # Test normal payload
-    response = await client.post(
-        app.url_path_for("create_slot", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
-    slots = result.scalars().all()
-    assert len(slots) > 1
-    slot = [s for s in slots if s.id != default_slot_id][0]
-    assert slot.required_volunteers == 2
-
-
-async def test_update_slot(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        )
-    )
-    assert response.status_code == 401
-
-    starting_time = datetime(2000, 1, 1, tzinfo=timezone.utc)
-    payload = {
-        "title": "être mort 2 fois",
-        "starting_time": starting_time.isoformat(),
-        "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
-        "required_volunteers": 2,
-    }
-
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=uuid.uuid4(),
-            slot_id=default_slot_id,
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # test invalid slot_id
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=uuid.uuid4(),
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-
-    for k, v in payload.items():
-        response = await client.post(
-            app.url_path_for(
-                "update_slot",
-                project_id=default_project_id,
-                slot_id=default_slot_id,
-            ),
-            json={k: v},
-            headers=default_user_headers,
-        )
-        assert response.status_code == 200
-        assert response.json()["id"] == default_slot_id
-        if "time" in k:
-            assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(v)
-        else:
-            assert response.json()[k] == v
-
-
-async def test_update_slot_remove_template(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"template_id": default_template_id},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    slot = session.execute(select(Slot).where(Slot.project_id == default_project_id)).scalar_one()
-    assert slot.template_id == default_template_id
-
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"template_id": ""},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    session.refresh(slot)
-    assert slot.template_id is None
-
-
-async def test_update_bad_slot_template(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-):
-    path = app.url_path_for(
-        "update_slot",
-        project_id=default_project_id,
-        slot_id=default_slot_id,
-    )
-    response = await client.post(
-        path,
-        json={"template_id": "invalid uuid"},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    response = await client.post(
-        path,
-        json={"template_id": str(uuid.uuid4())},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 400
-
-
-async def test_update_slot_template(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    path = app.url_path_for(
-        "update_slot",
-        project_id=default_project_id,
-        slot_id=default_slot_id,
-    )
-    template = SlotTemplate(project_id=default_project_id, title="template 0")
-    session.add(template)
-    session.commit()
-
-    response = await client.post(
-        path,
-        json={"template_id": template.id},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["template_id"] == template.id
-
-    session.refresh(template)
-    assert len(template.slots) == 1
-
-    response = await client.post(
-        path,
-        json={"template_id": None},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["template_id"] is None
-
-
-async def test_update_slot_volunteers(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": []},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 200
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer = result.scalars().first()
-    assert volunteer is not None
-    assert volunteer.slots_id == []
-
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": [default_volunteer_id]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 200
-    session.refresh(volunteer)
-    assert volunteer is not None
-    assert volunteer.slots_id == [default_slot_id]
-
-    # An invalid volunteer list
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": [str(uuid.uuid4())]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 400
-
-    # An invalid volunteer list
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": ["not uuid str"]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 422
-
-
-async def test_delete_slot_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail deleting the project due to not logged in
-    response = await client.delete(
-        app.url_path_for(
-            "delete_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        )
-    )
-    assert response.status_code == 401
-    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
-    slot = result.scalars().first()
-    assert slot is not None
-
-    # Cannot delete non uuid string
-    response = await client.delete(
-        app.url_path_for("delete_slot", project_id=default_project_id, slot_id="not uidstr"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_delete_slot(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    response = await client.delete(
-        app.url_path_for(
-            "delete_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
-    slot = result.scalars().first()
-    assert slot is None
-
-    # check deletion is cascaded to volunteers
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer: Volunteer | None = result.scalars().first()
-    assert volunteer is not None
-    assert default_slot_id not in volunteer.slots_id
-
-    # can delete random uuid
-    response = await client.delete(
-        app.url_path_for("delete_slot", project_id=default_project_id, slot_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-
-async def test_delete_slot_idempotent(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Idempotence test
-    url = app.url_path_for("delete_slot", project_id=default_project_id, slot_id=default_slot_id)
-    response = await client.delete(url, headers=default_user_headers)
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200

+ 681 - 0
app/tests/test_slots.py

@@ -0,0 +1,681 @@
+from datetime import UTC, datetime, timedelta
+from uuid import uuid4
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import (
+    Commission,
+    OrgRole,
+    Project,
+    Slot,
+    SlotTemplate,
+    Volunteer,
+)
+from app.tests.conftest import (
+    default_project_id,
+    default_slot_id,
+    default_template_id,
+    default_volunteer_id,
+)
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+def commission_and_member(session: Session, default_public_project: Project, make_org_user):
+    commission = Commission(project_id=default_public_project.id, name="Bar")
+    session.add(commission)
+    session.commit()
+    user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
+    commission.members.append(user)
+    session.commit()
+    return commission, user, headers
+
+
+@pytest.fixture
+def other_commission(session: Session, default_public_project: Project):
+    commission = Commission(project_id=default_public_project.id, name="Scene")
+    session.add(commission)
+    session.commit()
+    return commission
+
+
+@pytest.fixture
+def commission_template(
+    session: Session, default_public_project: Project, commission_and_member
+) -> SlotTemplate:
+    commission, _, _ = commission_and_member
+    template = SlotTemplate(
+        project_id=default_public_project.id, title="Bar template", commission_id=commission.id
+    )
+    session.add(template)
+    session.commit()
+    return template
+
+
+@pytest.fixture
+def other_commission_template(
+    session: Session, default_public_project: Project, other_commission
+) -> SlotTemplate:
+    template = SlotTemplate(
+        project_id=default_public_project.id,
+        title="Scene template",
+        commission_id=other_commission.id,
+    )
+    session.add(template)
+    session.commit()
+    return template
+
+
+t1 = datetime(2000, 1, 1, tzinfo=UTC)
+SLOT_ROUTES = [
+    ("GET", "list_project_slots", {"project_id": "PROJECT"}, None),
+    (
+        "POST",
+        "create_slot",
+        {"project_id": "PROJECT"},
+        {
+            "title": "Test",
+            "starting_time": t1.isoformat(),
+            "ending_time": (t1 + timedelta(days=1)).isoformat(),
+        },
+    ),
+    (
+        "POST",
+        "update_slot",
+        {"project_id": "PROJECT", "slot_id": default_slot_id},
+        {"title": "Updated"},
+    ),
+    ("DELETE", "delete_slot", {"project_id": "PROJECT", "slot_id": default_slot_id}, None),
+]
+
+
+class TestSlotCrossCuttingAccess(SharedProjectAccessTests):
+    @pytest.fixture(params=SLOT_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+        method, endpoint, kwargs, payload = request.param
+        resolved_kwargs = {
+            k: (default_project.id if v == "PROJECT" else v) for k, v in kwargs.items()
+        }
+        return method, endpoint, resolved_kwargs, payload
+
+
+class TestListProjectSlots:
+    async def test_invalid_project_id(
+        self, client: AsyncClient, default_user_headers: dict, default_user_org_membership
+    ):
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id="pas un uuid valid"),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_returns_slots(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id=default_project_id),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["title"] == "être roi"
+        assert data[0]["id"] == default_slot_id
+        assert "created_at" in data[0]
+
+    async def test_respo_commission_sees_all_project_slots(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+    ):
+        """respo_commission has read access to the whole plan, not just
+        their own commission's slots."""
+        _, _, headers = commission_and_member
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == 200
+        assert len(response.json()) == 1
+
+    async def test_cannot_look_other_org_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        session: Session,
+        commission_and_member,
+        other_org_user,
+    ):
+        """An admin of a project belonging to a DIFFERENT organization cannot access the project."""
+        _, headers = other_org_user
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id=default_public_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+
+class TestCreateSlot:
+    async def test_org_admin_creates(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        starting_time = datetime(1900, 1, 1)
+        payload = {
+            "title": "être mort",
+            "starting_time": starting_time.isoformat(),
+            "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+        }
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] != default_slot_id
+        assert response.json()["title"] == "être mort"
+        assert response.json()["required_volunteers"] == 0
+
+        result = session.execute(select(Slot).where(Slot.project_id == default_project_id))
+        slots = result.scalars().all()
+        assert len(slots) > 1
+        slot = [s for s in slots if s.id != default_slot_id][0]
+        assert slot.title == "être mort"
+        assert abs(slot.starting_time - starting_time.replace(tzinfo=UTC)) < timedelta(minutes=30)
+
+        del payload["title"]
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_min_volunteer(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        starting_time = datetime(1900, 1, 1)
+        payload = {
+            "title": "être mort",
+            "starting_time": starting_time.isoformat(),
+            "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+            "required_volunteers": 2,
+        }
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        slots = (
+            session.execute(select(Slot).where(Slot.project_id == default_project_id))
+            .scalars()
+            .all()
+        )
+        slot = [s for s in slots if s.id != default_slot_id][0]
+        assert slot.required_volunteers == 2
+
+    async def test_respo_benevole_creates_without_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Benevole slot",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+            },
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    async def test_respo_commission_creates_with_own_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        commission_template,
+    ):
+        _, _, headers = commission_and_member
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Bar shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "template_id": commission_template.id,
+            },
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["template_id"] == commission_template.id
+
+    async def test_respo_commission_rejected_with_other_commission_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission_template,
+    ):
+        _, _, headers = commission_and_member
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Scene shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "template_id": other_commission_template.id,
+            },
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_with_invalid_volunteer(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Scene shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "volunteers": [str(uuid4())],
+            },
+            headers=headers,
+        )
+        assert response.status_code == 400
+
+    async def test_with_volunteer(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        starting_time = datetime(1900, 1, 1)
+        response = await client.post(
+            app.url_path_for("create_slot", project_id=default_project_id),
+            json={
+                "title": "Scene shift",
+                "starting_time": starting_time.isoformat(),
+                "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+                "volunteers": [default_volunteer_id],
+            },
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+
+class TestUpdateSlot:
+    async def test_slot_not_found(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=uuid4()),
+            json={"title": "x"},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 404
+
+    async def test_org_admin_updates_each_field(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        starting_time = datetime(2000, 1, 1, tzinfo=UTC)
+        payload = {
+            "title": "être mort 2 fois",
+            "starting_time": starting_time.isoformat(),
+            "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
+            "required_volunteers": 2,
+        }
+        for k, v in payload.items():
+            response = await client.post(
+                app.url_path_for(
+                    "update_slot", project_id=default_project_id, slot_id=default_slot_id
+                ),
+                json={k: v},
+                headers=default_user_headers,
+            )
+            assert response.status_code == 200
+            assert response.json()["id"] == default_slot_id
+            if "time" in k:
+                assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(v)
+            else:
+                assert response.json()[k] == v
+
+    async def test_remove_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=default_slot_id),
+            json={"template_id": default_template_id},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        slot = session.execute(
+            select(Slot).where(Slot.project_id == default_project_id)
+        ).scalar_one()
+        assert slot.template_id == default_template_id
+
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=default_slot_id),
+            json={"template_id": ""},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        session.refresh(slot)
+        assert slot.template_id is None
+
+    async def test_bad_template_id(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        path = app.url_path_for(
+            "update_slot", project_id=default_project_id, slot_id=default_slot_id
+        )
+        response = await client.post(
+            path, json={"template_id": "invalid uuid"}, headers=default_user_headers
+        )
+        assert response.status_code == 422
+        response = await client.post(
+            path, json={"template_id": str(uuid4())}, headers=default_user_headers
+        )
+        assert response.status_code == 400
+
+    async def test_assign_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        path = app.url_path_for(
+            "update_slot", project_id=default_project_id, slot_id=default_slot_id
+        )
+        template = SlotTemplate(project_id=default_project_id, title="template 0")
+        session.add(template)
+        session.commit()
+
+        response = await client.post(
+            path, json={"template_id": template.id}, headers=default_user_headers
+        )
+        assert response.status_code == 200
+        assert response.json()["template_id"] == template.id
+        session.refresh(template)
+        assert len(template.slots) == 1
+
+        response = await client.post(path, json={"template_id": None}, headers=default_user_headers)
+        assert response.status_code == 200
+        assert response.json()["template_id"] is None
+
+    async def test_update_volunteers(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        path = app.url_path_for(
+            "update_slot", project_id=default_project_id, slot_id=default_slot_id
+        )
+
+        response = await client.post(path, json={"volunteers": []}, headers=default_user_headers)
+        assert response.status_code == 200
+        volunteer = (
+            session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
+            .scalars()
+            .first()
+        )
+        assert volunteer.slots_id == []
+
+        response = await client.post(
+            path, json={"volunteers": [default_volunteer_id]}, headers=default_user_headers
+        )
+        assert response.status_code == 200
+        session.refresh(volunteer)
+        assert volunteer.slots_id == [default_slot_id]
+
+        response = await client.post(
+            path, json={"volunteers": [str(uuid4())]}, headers=default_user_headers
+        )
+        assert response.status_code == 400
+
+        response = await client.post(
+            path, json={"volunteers": ["not uuid str"]}, headers=default_user_headers
+        )
+        assert response.status_code == 422
+
+    async def test_respo_commission_updates_own_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        commission_template,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        slot = Slot(
+            project_id=default_project_id,
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
+            json={"title": "Bar shift renamed"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        session.refresh(slot)
+        assert slot.title == "Bar shift renamed"
+
+    async def test_respo_commission_rejected_for_other_commission_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission_template,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        slot = Slot(
+            project_id=default_project_id,
+            title="Scene shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=other_commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
+            json={"title": "Hijacked"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_respo_commission_reassign_to_other_commission_template_rejected(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        commission_template,
+        other_commission_template,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        slot = Slot(
+            project_id=default_project_id,
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
+            json={"template_id": other_commission_template.id},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+
+class TestDeleteSlot:
+    async def test_invalid_slot_id(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.delete(
+            app.url_path_for("delete_slot", project_id=default_project_id, slot_id="not uidstr"),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_org_admin_deletes(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        response = await client.delete(
+            app.url_path_for("delete_slot", project_id=default_project_id, slot_id=default_slot_id),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Slot, default_slot_id) is None
+
+        volunteer = (
+            session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
+            .scalars()
+            .first()
+        )
+        assert default_slot_id not in volunteer.slots_id
+
+    async def test_nonexistent_slot_returns_404(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        """require_commission_scope resolves the slot before the handler
+        runs, so a missing slot_id now 404s -- differs from the old bulk
+        DELETE behavior, which silently returned 200 either way."""
+        response = await client.delete(
+            app.url_path_for("delete_slot", project_id=default_project_id, slot_id=uuid4()),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 404
+
+    async def test_respo_commission_deletes_own_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        commission_template,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        slot = Slot(
+            project_id=default_project_id,
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
+        slot_id = slot.id
+        session.expunge(slot)
+        response = await client.delete(
+            app.url_path_for("delete_slot", project_id=default_project_id, slot_id=slot_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Slot, slot_id) is None
+
+    async def test_respo_commission_rejected_for_other_commission_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission_template,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        slot = Slot(
+            project_id=default_project_id,
+            title="Scene shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=other_commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
+
+        response = await client.delete(
+            app.url_path_for("delete_slot", project_id=default_project_id, slot_id=slot.id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+        assert session.get(Slot, slot.id) is not None

+ 290 - 252
app/tests/test_sms.py

@@ -1,263 +1,301 @@
-from datetime import datetime, timezone
 import uuid
+from datetime import UTC, datetime
+
+import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Sms, Volunteer
-from app.tests.conftest import default_project_id, default_volunteer_id, default_sms_id
-
-
-async def test_read_list_project_sms(
-    client: AsyncClient, default_user_headers: dict, default_public_project: Project
-):
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=default_project_id),
-    )
-    assert response.status_code == 401
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id="pas un uuid valid"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-    assert data[0]["id"] == default_sms_id
-
-
-async def test_create_sms(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(app.url_path_for("create_sms", project_id=default_project_id))
-    assert response.status_code == 401
-    payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=uuid.uuid4()),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-    before_creation_time = datetime.now(timezone.utc)
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["content"] == "sms_content"
-    result = session.execute(
-        select(Sms).where((Sms.project_id == default_project_id) & (Sms.id != default_sms_id))
-    )
-    sms = result.scalars().first()
-
-    assert sms is not None
-    assert sms.content == "sms_content"
-    assert before_creation_time < sms.sending_time
-    assert sms.sending_time < datetime.now(timezone.utc)
-
-    # test invalid payload
-    del payload["content"]
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_update_sms(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(
-        app.url_path_for(
-            "update_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
+from app.models import OrgRole, Project, Sms, Volunteer
+from app.tests.conftest import default_project_id, default_sms_id, default_volunteer_id
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+ALL_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+route_kwarg = {"project_id": default_project_id}
+route_kwargs = {**route_kwarg, "sms_id": default_sms_id}
+SMS_ROUTES = [
+    ("GET", "list_project_sms", route_kwarg, None),
+    (
+        "POST",
+        "create_sms",
+        route_kwarg,
+        {
+            "phone_number": "Test",
+            "content": "Coucou",
+        },
+    ),
+    ("POST", "update_sms", route_kwargs, {"title": "Updated"}),
+    ("DELETE", "delete_sms", route_kwargs, None),
+]
+
+
+class TestSMSCrossCutting(SharedProjectAccessTests):
+    @pytest.fixture(params=SMS_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+
+        return request.param
+
+
+class TestListProjectSms:
+    async def test_invalid_project_id_format(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for("list_project_sms", project_id="pas un uuid valid"), headers=headers
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_read(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_sms", project_id=default_project_id), headers=headers
         )
-    )
-    assert response.status_code == 401
-
-    # Test invalid payload
-    response = await client.post(
-        app.url_path_for(
-            "update_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
-        ),
-        json={"volunteer_id": True},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    payload = {
-        "volunteer_id": default_volunteer_id,
-        "phone_number": "06 75 75 75 75 ",
-        "content": "sms_content",
-        "sending_time": datetime(2024, 5, 17, tzinfo=timezone.utc).isoformat(),
-    }
-
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for(
-            "update_sms",
-            project_id=uuid.uuid4(),
-            sms_id=default_sms_id,
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # test invalid sms_id
-    response = await client.post(
-        app.url_path_for(
-            "update_sms",
-            project_id=default_project_id,
-            sms_id=uuid.uuid4(),
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-    for k, v in payload.items():
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["id"] == default_sms_id
+
+
+class TestCreateSms:
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_create(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
+
+        before_creation_time = datetime.now(UTC)
         response = await client.post(
-            app.url_path_for(
-                "update_sms",
-                project_id=default_project_id,
-                sms_id=default_sms_id,
-            ),
-            json={k: v},
-            headers=default_user_headers,
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
         )
         assert response.status_code == 200
-        assert response.json()["id"] == default_sms_id
-        if "time" in k:
-            parsed_time = datetime.fromisoformat(response.json()[k])
-            assert parsed_time == datetime.fromisoformat(v)
-        else:
-            assert response.json()[k] == v
-
-
-async def test_delete_sms(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail deleting the project due to not logged in
-    response = await client.delete(
-        app.url_path_for(
-            "delete_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
+        assert response.json()["content"] == "sms_content"
+
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == "sms_content"
+        assert before_creation_time < sms.sending_time
+        assert sms.sending_time < datetime.now(UTC)
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
+        response = await client.post(
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_invalid_payload(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {"phone_number": "06 75 75 75 75 "}  # missing content
+        response = await client.post(
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+
+class TestUpdateSms:
+    async def test_invalid_payload(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id),
+            json={"volunteer_id": True},
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    async def test_sms_not_found(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {
+            "volunteer_id": default_volunteer_id,
+            "phone_number": "06 75 75 75 75 ",
+            "content": "sms_content",
+            "sending_time": datetime(2024, 5, 17, tzinfo=UTC).isoformat(),
+        }
+        response = await client.post(
+            app.url_path_for("update_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 404
+
+    async def test_invalid_project_id_format(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("update_sms", project_id="pas un uuid valid", sms_id=default_sms_id),
+            json={"content": "x"},
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_update_each_field(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        payload = {
+            "volunteer_id": default_volunteer_id,
+            "phone_number": "06 75 75 75 75 ",
+            "content": "sms_content",
+            "sending_time": datetime(2024, 5, 17, tzinfo=UTC).isoformat(),
+        }
+        for k, v in payload.items():
+            response = await client.post(
+                app.url_path_for(
+                    "update_sms", project_id=default_project_id, sms_id=default_sms_id
+                ),
+                json={k: v},
+                headers=headers,
+            )
+            assert response.status_code == 200
+            assert response.json()["id"] == default_sms_id
+            if "time" in k:
+                parsed_time = datetime.fromisoformat(response.json()[k])
+                assert parsed_time == datetime.fromisoformat(v)
+            else:
+                assert response.json()[k] == v
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.post(
+            app.url_path_for("update_sms", project_id=default_project_id, sms_id=default_sms_id),
+            json={"content": "hijacked"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+
+class TestDeleteSms:
+    async def test_invalid_sms_id_format(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_sms", project_id=default_project_id, sms_id="not uidstr"),
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.delete(
+            app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+        assert session.get(Sms, default_sms_id) is not None
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_delete(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.delete(
+            app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Sms, default_sms_id) is None
+
+    async def test_idempotent_delete(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id)
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+
+    async def test_delete_random_uuid(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        """sms.py still uses bulk DELETE without an existence check --
+        matches volunteers.py's behavior, not slots.py/templates.py's 404.
+        Same cross-router inconsistency flagged for delete_volunteer."""
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    async def test_delete_cascades_from_volunteer(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        volunteer = (
+            session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
+            .scalars()
+            .first()
+        )
+        assert len(volunteer.sms) == 0
+
+        sms = session.get(Sms, default_sms_id)
+        sms.volunteer_id = default_volunteer_id
+        session.commit()
+        session.refresh(volunteer)
+        assert len(volunteer.sms) == 1, "Sms not added to volunteer"
+        session.expunge(sms)
+
+        response = await client.delete(
+            app.url_path_for("delete_sms", project_id=default_project_id, sms_id=default_sms_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Sms, default_sms_id) is None
+
+        session.refresh(volunteer)
+        assert len(volunteer.sms) == 0, "Sms should be deleted"
+
+        volunteer_after = (
+            session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
+            .scalars()
+            .first()
         )
-    )
-    assert response.status_code == 401
-    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
-    sms = result.scalars().first()
-    assert sms is not None
-
-    # Proper deletion
-    response = await client.delete(
-        app.url_path_for(
-            "delete_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
-    sms = result.scalars().first()
-    assert sms is None
-
-    # Idempotence test
-    response = await client.delete(
-        app.url_path_for(
-            "delete_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    # can delete random uuid
-    response = await client.delete(
-        app.url_path_for("delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    # Cannot delete non uuid string
-    response = await client.delete(
-        app.url_path_for("delete_sms", project_id=default_project_id, sms_id="not uidstr"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_delete_sms_with_volunteer(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer = result.scalars().first()
-    assert volunteer is not None
-    assert len(volunteer.sms) == 0
-
-    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
-    sms = result.scalars().first()
-    assert sms is not None
-    sms.volunteer_id = default_volunteer_id
-    session.commit()
-
-    session.refresh(volunteer)
-    assert len(volunteer.sms) == 1, "Sms not added to volunteer"
-
-    response = await client.delete(
-        app.url_path_for(
-            "delete_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Sms).where(Sms.id == default_sms_id))
-    assert result.scalars().first() is None
-
-    session.refresh(volunteer)
-    assert len(volunteer.sms) == 0, "Sms should be deleted"
-
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer_result = result.scalars().first()
-    assert volunteer_result is not None, "Volunteer should not be deleted"
-    assert len(volunteer_result.sms) == 0, "No more sms associated to volunteer"
+        assert volunteer_after is not None, "Volunteer should not be deleted"
+        assert len(volunteer_after.sms) == 0

+ 504 - 281
app/tests/test_sms_batch.py

@@ -1,13 +1,27 @@
 from datetime import datetime, timedelta
-from httpx import AsyncClient
+
 import pytest
+from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Slot, SlotTemplate, Sms, Volunteer
+from app.models import (
+    Commission,
+    OrgRole,
+    Project,
+    Slot,
+    SlotTemplate,
+    Sms,
+    User,
+    Volunteer,
+)
 from app.tests.conftest import default_sms_id, default_volunteer_id
 
+pytestmark = pytest.mark.asyncio
+
+WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
+
 
 def get_arthur(session) -> Volunteer:
     result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
@@ -16,314 +30,523 @@ def get_arthur(session) -> Volunteer:
     return arthur
 
 
-async def test_create_sms_batch_bad_input(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        json={"is_public": False},
-    )
-    assert response.status_code == 401
+@pytest.fixture()
+def user_headers(make_org_user) -> dict:
+    _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+    return headers
 
 
-async def test_basic(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=datetime.now() + timedelta(minutes=30),
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    slot.volunteers.append(get_arthur(session))
-    session.add(slot)
-    session.commit()
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}"},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+class TestCreateSmsBatchAuth:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            json={"is_public": False},
         )
-    )
-    sms = result.scalars().first()
-    assert sms is not None
-    assert sms.content == "Bonjour Arthur!\nreplanter excalibure"
-
+        assert response.status_code == 401
 
-async def test_multiple_volunteer(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=datetime.now() + timedelta(minutes=30),
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    slot.volunteers.append(get_arthur(session))
-    slot.volunteers.append(
-        Volunteer(
-            project_id=default_public_project.id,
-            name="benevole 2",
-            surname="echo",
-            phone_number="t",
-            email="",
-            automatic_sms=True,
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            json={"is_public": False},
+            headers=headers,
         )
-    )
+        assert response.status_code == 403
 
-    session.add(slot)
-    session.commit()
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}"},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
+    async def test_write_roles_allowed(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=headers,
+            json={"template": "Bonjour!"},
         )
-    )
-    count_sms = 0
-    for _ in result.fetchall():
-        count_sms += 1
-    assert count_sms == 2
+        assert response.status_code == 200
 
-
-@pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
-async def test_volunteer_settings(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    automatic_sms: bool,
-    expected: bool,
-):
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=datetime.now() + timedelta(minutes=30),
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    arthur = get_arthur(session)
-    arthur.automatic_sms = automatic_sms
-    slot.volunteers.append(arthur)
-    session.add(slot)
-    session.commit()
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}"},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
+    async def test_read_only_roles_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=headers,
+            json={"template": "Bonjour!"},
         )
-    )
-    sms = result.scalars().first()
-    assert (sms is not None) == expected
+        assert response.status_code == 403
 
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=headers,
+            json={"template": "Bonjour!"},
+        )
+        assert response.status_code == 403
 
-async def test_only_future_slot(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Create a slot starting in the past
-    # no sms should be issued
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=datetime.now() + timedelta(minutes=-30),
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    slot.volunteers.append(get_arthur(session))
 
-    session.add(slot)
-    session.commit()
+class TestCreateSmsBatchContent:
+    """Content-generation behavior -- exercised as respo_benevole throughout,
+    since role gating itself is covered separately in TestCreateSmsBatchAuth."""
 
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}"},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    async def test_basic(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=datetime.now() + timedelta(minutes=30),
+            ending_time=datetime.now() + timedelta(minutes=60),
         )
-    )
-    sms = result.scalars().first()
-    assert sms is None
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "Bonjour {prenom}!\n{titre}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == "Bonjour Arthur!\nreplanter excalibure"
 
+    async def test_multiple_volunteer(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=datetime.now() + timedelta(minutes=30),
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.volunteers.append(get_arthur(session))
+        slot.volunteers.append(
+            Volunteer(
+                project_id=default_public_project.id,
+                name="benevole 2",
+                surname="echo",
+                phone_number="t",
+                email="",
+                automatic_sms=True,
+            )
+        )
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "Bonjour {prenom}!\n{titre}"},
+        )
+        assert response.status_code == 200
+        result = session.execute(
+            select(Sms).where(
+                (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+            )
+        )
+        count_sms = sum(1 for _ in result.fetchall())
+        assert count_sms == 2
 
-@pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
-async def test_time_delta(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    delta_t: int,
-    expected,
-):
-    # Create a slot starting in the past
-    # no sms should be issued
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=datetime.now() + timedelta(minutes=30),
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    slot.volunteers.append(get_arthur(session))
+    @pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
+    async def test_volunteer_settings(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+        automatic_sms: bool,
+        expected: bool,
+    ):
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=datetime.now() + timedelta(minutes=30),
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        arthur = get_arthur(session)
+        arthur.automatic_sms = automatic_sms
+        slot.volunteers.append(arthur)
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "Bonjour {prenom}!\n{titre}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert (sms is not None) == expected
 
-    session.add(slot)
-    session.commit()
+    async def test_only_future_slot(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=datetime.now() + timedelta(minutes=-30),
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "Bonjour {prenom}!\n{titre}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is None
 
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    @pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
+    async def test_time_delta(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+        delta_t: int,
+        expected,
+    ):
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=datetime.now() + timedelta(minutes=30),
+            ending_time=datetime.now() + timedelta(minutes=60),
         )
-    )
-    sms = result.scalars().first()
-    assert (sms is not None) == expected
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert (sms is not None) == expected
 
 
 starting_time = datetime.now() + timedelta(minutes=30)
 
 
-@pytest.mark.parametrize(
-    "template,expected",
-    [
-        ("no escape", "no escape"),
-        ("{titre}", "replanter excalibure"),
-        ("{prenom}", "Arthur"),
-        ("{prenom} {nom}", "Arthur Pandragon"),
-        ("{debut}", starting_time.strftime("%Hh%M")),
-        (
-            "{titre} {description}, {respo}",
-            "replanter excalibure {description}, {respo}",
-        ),
-    ],
-)
-async def test_content_no_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    template: str,
-    expected: str,
-):
-    # Create a slot starting in the past
-    # no sms should be issued
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=starting_time,
-        ending_time=datetime.now() + timedelta(minutes=60),
+class TestSmsPlaceholders:
+    @pytest.mark.parametrize(
+        "template,expected",
+        [
+            ("no escape", "no escape"),
+            ("{titre}", "replanter excalibure"),
+            ("{prenom}", "Arthur"),
+            ("{prenom} {nom}", "Arthur Pandragon"),
+            ("{debut}", starting_time.strftime("%Hh%M")),
+            ("{titre} {description}, {respo}", "replanter excalibure {description}, {respo}"),
+        ],
     )
-    slot.volunteers.append(get_arthur(session))
-
-    session.add(slot)
-    session.commit()
+    async def test_content_no_template(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+        template: str,
+        expected: str,
+    ):
+        """No SlotTemplate attached at all -- {respo}/{description} placeholders
+        are left untouched, since the replace only happens when slot.template
+        is not None."""
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=starting_time,
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": template},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == expected
 
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": template},
+    @pytest.mark.parametrize(
+        "template,expected",
+        [
+            ("no escape", "no escape"),
+            ("{titre}", "replanter excalibure"),
+            ("{prenom}", "Arthur"),
+            ("{prenom} {nom}", "Arthur Pandragon"),
+            ("{debut}", starting_time.strftime("%Hh%M")),
+        ],
     )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    async def test_content_with_template_non_respo_placeholders(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+        template: str,
+        expected: str,
+    ):
+        """Placeholders unrelated to {respo} behave the same regardless of
+        how the contact is resolved."""
+        slot_template = SlotTemplate(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            description="rendre l'épee à son caillou",
+            place="gros caillou",
         )
-    )
-    sms = result.scalars().first()
-    assert sms is not None
-    assert sms.content == expected
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=starting_time,
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.template = slot_template
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot_template)
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": template},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == expected
 
+    async def test_respo_placeholder_uses_override_when_set(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        """responsible_override wins even if a commission is also attached."""
+        commission = Commission(project_id=default_public_project.id, name="Bar")
+        session.add(commission)
+        session.commit()
+        slot_template = SlotTemplate(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            description="rendre l'épee à son caillou",
+            place="gros caillou",
+            commission_id=commission.id,
+            responsible_override="dieu",
+        )
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=starting_time,
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.template = slot_template
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot_template)
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "{titre} {description}, {respo}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == "replanter excalibure rendre l'épee à son caillou, dieu"
 
-@pytest.mark.parametrize(
-    "template,expected",
-    [
-        ("no escape", "no escape"),
-        ("{titre}", "replanter excalibure"),
-        ("{prenom}", "Arthur"),
-        ("{prenom} {nom}", "Arthur Pandragon"),
-        ("{debut}", starting_time.strftime("%Hh%M")),
-        (
-            "{titre} {description}, {respo}",
-            "replanter excalibure rendre l'épee à son caillou, dieu",
-        ),
-    ],
-)
-async def test_content_with_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    template: str,
-    expected: str,
-):
-    # Create a slot starting in the past
-    # no sms should be issued
-    slot_template = SlotTemplate(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        description="rendre l'épee à son caillou",
-        responsible_contact="dieu",
-        place="gros caillou",
-    )
-    slot = Slot(
-        project_id=default_public_project.id,
-        title="replanter excalibure",
-        starting_time=starting_time,
-        ending_time=datetime.now() + timedelta(minutes=60),
-    )
-    slot.template = slot_template
-    slot.volunteers.append(get_arthur(session))
-    session.add(slot_template)
-    session.add(slot)
-    session.commit()
+    async def test_respo_placeholder_derives_from_commission_members(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        """No override set -- {respo} falls back to the commission's members'
+        name : phone_number, comma-joined."""
+        commission = Commission(project_id=default_public_project.id, name="Bar")
+        session.add(commission)
+        session.commit()
+        member1 = User(
+            id="c1a1a1a1-0000-0000-0000-000000000001",
+            email="merlin@test.com",
+            hashed_password="x",
+            name="Merlin",
+            phone_number="0601020304",
+        )
+        member2 = User(
+            id="c1a1a1a1-0000-0000-0000-000000000002",
+            email="morgane@test.com",
+            hashed_password="x",
+            name="Morgane",
+            phone_number="0605060708",
+        )
+        session.add_all([member1, member2])
+        session.commit()
+        commission.members.append(member1)
+        commission.members.append(member2)
+        session.commit()
+        slot_template = SlotTemplate(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            description="rendre l'épee à son caillou",
+            place="gros caillou",
+            commission_id=commission.id,
+        )
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=starting_time,
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.template = slot_template
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot_template)
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "{titre} {description}, {respo}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == (
+            "replanter excalibure rendre l'épee à son caillou, "
+            "Merlin : 0601020304, Morgane : 0605060708"
+        )
 
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
-        json={"template": template},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+    async def test_respo_placeholder_empty_when_no_commission_or_override(
+        self,
+        client: AsyncClient,
+        user_headers: dict,
+        session: Session,
+        default_public_project: Project,
+    ):
+        """Template attached but no commission and no override -- {respo}
+        resolves to an empty string rather than leaving the placeholder
+        literal (that untouched-placeholder behavior only applies when
+        slot.template is None, covered in test_content_no_template)."""
+        slot_template = SlotTemplate(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            description="rendre l'épee à son caillou",
+            place="gros caillou",
         )
-    )
-    sms = result.scalars().first()
-    assert sms is not None
-    assert sms.content == expected
+        slot = Slot(
+            project_id=default_public_project.id,
+            title="replanter excalibure",
+            starting_time=starting_time,
+            ending_time=datetime.now() + timedelta(minutes=60),
+        )
+        slot.template = slot_template
+        slot.volunteers.append(get_arthur(session))
+        session.add(slot_template)
+        session.add(slot)
+        session.commit()
+        response = await client.post(
+            app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+            headers=user_headers,
+            json={"template": "{titre}, {respo}"},
+        )
+        assert response.status_code == 200
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == "replanter excalibure, "

+ 266 - 0
app/tests/test_sms_sender.py

@@ -0,0 +1,266 @@
+import datetime
+import uuid
+from datetime import timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.main import app
+from app.models import GlobalRole, OrgRole, Project, ServerStatus, Sms
+from app.schemas.responses import EnumServerStatus
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+def super_admin_headers(make_org_user) -> dict:
+    """Fixture to get headers for a user with SUPER_ADMIN global role."""
+    _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.SUPER_ADMIN)
+    return headers
+
+
+@pytest.fixture
+def standard_user_headers(make_org_user) -> dict:
+    """Fixture to get headers for a standard user."""
+    _, headers = make_org_user(role=OrgRole.ORG_ADMIN, global_role=GlobalRole.USER)
+    return headers
+
+
+@pytest.fixture
+def sms_records(session: Session, default_project: Project) -> dict[str, Sms]:
+    """Creates a matrix of SMS records to test various time and state conditions."""
+    now = datetime.datetime.now()
+
+    # Ready to send (within the default 10 min window)
+    sms_to_send = Sms(
+        project_id=default_project.id,
+        content="to send",
+        phone_number="0600000001",
+        sending_time=now - timedelta(minutes=5),
+    )
+
+    # Too old (missed the default 10 min window)
+    sms_too_old = Sms(
+        project_id=default_project.id,
+        content="too old",
+        phone_number="0600000002",
+        sending_time=now - timedelta(minutes=15),
+    )
+
+    # Scheduled for the future
+    sms_future = Sms(
+        project_id=default_project.id,
+        content="future",
+        phone_number="0600000003",
+        sending_time=now + timedelta(minutes=10),
+    )
+
+    # 4. Already sent
+    sms_sent = Sms(
+        project_id=default_project.id,
+        content="sent",
+        phone_number="0600000004",
+        sending_time=now - timedelta(minutes=5),
+        send_time=now - timedelta(minutes=1),
+    )
+
+    session.add_all([sms_to_send, sms_too_old, sms_future, sms_sent])
+    session.commit()
+    session.refresh(sms_to_send)
+    session.refresh(sms_too_old)
+    session.refresh(sms_future)
+    session.refresh(sms_sent)
+
+    return {
+        "to_send": sms_to_send,
+        "too_old": sms_too_old,
+        "future": sms_future,
+        "sent": sms_sent,
+    }
+
+
+class TestSmsSenderSecurity:
+    """Ensure all endpoints are strictly protected by require_super_admin"""
+
+    @pytest.fixture(autouse=True)
+    def setup_routes(self, sms_records):
+        sms_id = sms_records["to_send"].id
+        self.routes = [
+            ("GET", "list_sms_to_send", {}, None),
+            ("POST", "send_sms_now", {"sms_id": sms_id}, None),
+            ("GET", "list_not_sent", {}, None),
+            ("GET", "list_future_sms", {}, None),
+            ("POST", "update_status", {}, None),
+            ("GET", "get_status", {}, None),
+        ]
+
+    async def test_rejects_unauthenticated(self, client: AsyncClient):
+        for method, endpoint, kwargs, payload in self.routes:
+            url = app.url_path_for(endpoint, **kwargs)
+            req = getattr(client, method.lower())
+            response = await req(url, json=payload) if payload else await req(url)
+            assert response.status_code == 401
+
+    async def test_rejects_standard_user(self, client: AsyncClient, standard_user_headers):
+        for method, endpoint, kwargs, payload in self.routes:
+            url = app.url_path_for(endpoint, **kwargs)
+            req = getattr(client, method.lower())
+            response = (
+                await req(url, json=payload, headers=standard_user_headers)
+                if payload
+                else await req(url, headers=standard_user_headers)
+            )
+            # require_super_admin usually throws 403
+            assert response.status_code == 403
+
+
+class TestListSmsToSend:
+    async def test_default_delay_window(
+        self, client: AsyncClient, super_admin_headers, sms_records
+    ):
+        """Should only return SMS scheduled in the past 10 minutes (default)."""
+        response = await client.get(
+            app.url_path_for("list_sms_to_send"), headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        assert len(data) == 1
+        assert data[0]["id"] == str(sms_records["to_send"].id)
+
+    async def test_custom_max_delay(self, client: AsyncClient, super_admin_headers, sms_records):
+        """Increasing max_delay to 20 mins should include the 'too_old' SMS."""
+        response = await client.get(
+            f"{app.url_path_for('list_sms_to_send')}?max_delay=20", headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        assert len(data) == 2
+        ids = [sms["id"] for sms in data]
+        assert str(sms_records["to_send"].id) in ids
+        assert str(sms_records["too_old"].id) in ids
+
+
+class TestSendSmsNow:
+    async def test_send_success_and_updates_status(
+        self, client: AsyncClient, super_admin_headers, sms_records, session: Session
+    ):
+        sms_id = sms_records["to_send"].id
+
+        # Ensure status is empty initially
+        assert session.query(ServerStatus).first() is None
+
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=sms_id),
+            headers=super_admin_headers,
+        )
+
+        assert response.status_code == 200
+        assert response.json()["send_time"] is not None
+
+        # Verify DB is updated
+        session.refresh(sms_records["to_send"])
+        assert sms_records["to_send"].send_time is not None
+
+        # Verify ServerStatus was updated implicitly by the router
+        status = session.query(ServerStatus).first()
+        assert status is not None
+        assert status.host == "127.0.0.1"  # HTTPX default test client host
+
+    async def test_already_sent_returns_400(
+        self, client: AsyncClient, super_admin_headers, sms_records
+    ):
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=sms_records["sent"].id),
+            headers=super_admin_headers,
+        )
+        assert response.status_code == 400
+        assert response.json()["detail"] == "SMS has already been sent"
+
+    async def test_not_found_returns_404(self, client: AsyncClient, super_admin_headers):
+        response = await client.post(
+            app.url_path_for("send_sms_now", sms_id=uuid.uuid4()),
+            headers=super_admin_headers,
+        )
+        assert response.status_code == 404
+
+
+class TestListNotSent:
+    async def test_returns_all_unsent(self, client: AsyncClient, super_admin_headers, sms_records):
+        response = await client.get(app.url_path_for("list_not_sent"), headers=super_admin_headers)
+        assert response.status_code == 200
+        data = response.json()
+
+        # Should include to_send, too_old, and future (everything where send_time is None)
+        assert len(data) == 3
+        ids = [sms["id"] for sms in data]
+        assert str(sms_records["sent"].id) not in ids
+
+
+class TestListFutureSms:
+    async def test_returns_only_future(self, client: AsyncClient, super_admin_headers, sms_records):
+        response = await client.get(
+            app.url_path_for("list_future_sms"), headers=super_admin_headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        # Should only include the future SMS
+        assert len(data) == 1
+        assert data[0]["id"] == str(sms_records["future"].id)
+
+
+class TestServerStatus:
+    async def test_post_status_creates_new(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("update_status"),
+            headers={**super_admin_headers, "user-agent": "test-agent"},
+        )
+        assert response.status_code == 200
+
+        status = session.query(ServerStatus).first()
+        assert status.id == 1
+        assert status.user_agent == "test-agent"
+
+    async def test_get_status_invalid_if_empty(self, client: AsyncClient, super_admin_headers):
+        """If the server has never pinged, it should return INVALID."""
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.INVALID.value
+
+    async def test_get_status_active(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        """Should be ACTIVE if pinged recently."""
+        status = ServerStatus(
+            id=1,
+            user_agent="python",
+            host="127.0.0.1",
+            updated_at=datetime.datetime.now(datetime.UTC),
+        )
+        session.add(status)
+        session.commit()
+
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.ACTIVE.value
+
+    async def test_get_status_inactive(
+        self, client: AsyncClient, super_admin_headers, session: Session
+    ):
+        """Should be INACTIVE if time since last ping exceeds settings threshold."""
+        old_time = datetime.datetime.now(datetime.UTC) - timedelta(
+            seconds=settings.INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS + 10
+        )
+        status = ServerStatus(id=1, user_agent="python", host="127.0.0.1", updated_at=old_time)
+        session.add(status)
+        session.commit()
+
+        response = await client.get(app.url_path_for("get_status"), headers=super_admin_headers)
+        assert response.status_code == 200
+        assert response.json()["message"] == EnumServerStatus.INACTIVE.value

+ 0 - 303
app/tests/test_tag.py

@@ -1,303 +0,0 @@
-from datetime import datetime
-import uuid
-from httpx import AsyncClient
-import pytest
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.main import app
-from app.models import Project, Slot, SlotTag, SlotTemplate
-from app.tests.conftest import default_project_id, default_tag_id
-
-
-async def test_read_list_project_tags(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    response = await client.get(
-        app.url_path_for("list_project_tags", project_id=default_project_id),
-    )
-    assert response.status_code == 401
-    response = await client.get(
-        app.url_path_for("list_project_tags", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert len(response.json()) == 1
-    tag = SlotTag(title="1er tag", project_id=default_project_id)
-    session.add(tag)
-    session.commit()
-    response = await client.get(
-        app.url_path_for("list_project_tags", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert len(response.json()) == 2
-
-
-async def test_create_tag_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    response = await client.post(
-        app.url_path_for("create_tag", project_id="default_project_id"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=uuid.uuid4()),
-        json={"title": "1st tag"},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-    response = await client.post(
-        app.url_path_for("create_tag", project_id="default_project_id"),
-    )
-    assert response.status_code == 401
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=default_project_id),
-        json={"titl": "1st tag"},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_create_tag(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_project: Project,
-    session: Session,
-):
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=default_project_id),
-        json={"title": "1st tag"},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
-    tag = result.scalars().first()
-    assert tag is not None
-    assert tag.title == "1st tag"
-
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=default_project_id),
-        json={"title": "1st tag"},
-        headers=default_user_headers,
-    )
-    result = session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
-    tag_count = 0
-    for tag in result.scalars():
-        tag_count += 1
-    assert tag_count == 2
-
-
-async def test_create_tag_with_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    template = SlotTemplate(
-        project_id=default_project_id,
-        title="coucou",
-        description="ceci est une description",
-    )
-    session.add(template)
-    session.commit()
-
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=default_project_id),
-        json={"title": "1st tag", "templates": [template.id]},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["templates_id"][0] == template.id
-    session.refresh(template)
-    assert len(template.tags) > 0
-
-    template = SlotTemplate(
-        project_id=default_project_id,
-        title="coucou",
-        description="ceci est une description",
-    )
-    session.add(template)
-    session.commit()
-
-    project = Project(name="second project", is_public=False)
-    session.add(project)
-    session.commit()
-    session.refresh(project)
-    response = await client.post(
-        app.url_path_for("create_tag", project_id=project.id),
-        json={"title": "1st tag", "templates": [template.id]},
-        headers=default_user_headers,
-    )
-    assert response.status_code == 400
-
-
-async def test_update_tag_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    # no authent
-    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.post(url, json={"title": "royaux"})
-    assert response.status_code == 401
-
-
-@pytest.mark.parametrize(
-    "payload", [{"title": [1.001, 2]}, {"title": {}}, {"templates": ["1", "2"]}]
-)
-async def test_update_tag_fail_invalid_payload(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-    payload: dict,
-):
-    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 422
-
-
-async def test_update_tag(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    tag = session.get(SlotTag, default_tag_id)
-    assert tag is not None
-    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
-
-    response = await client.post(url, json={"title": "new_title"}, headers=default_user_headers)
-    assert response.status_code == 200
-    session.refresh(tag)
-    assert tag.title == "new_title"
-    assert response.json()["title"] == "new_title"
-
-    template = SlotTemplate(project_id=default_project_id, title="template")
-    session.add(template)
-    session.commit()
-    response = await client.post(
-        url, json={"templates": [template.id]}, headers=default_user_headers
-    )
-    assert response.status_code == 200
-    session.refresh(tag)
-    assert len(tag.templates) == 1
-    response = await client.post(url, json={"templates": []}, headers=default_user_headers)
-    session.refresh(tag)
-    assert len(tag.templates) == 0
-
-
-async def test_update_tag_fail_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    default_public_project: Project,
-    session: Session,
-):
-    tag = session.get(SlotTag, default_tag_id)
-    assert tag is not None
-    project = Project(name="other project", is_public=False)
-    template = SlotTemplate(title="template 1")
-    project.templates.append(template)
-    session.add(template)
-    session.add(project)
-    session.commit()
-    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.post(
-        url, json={"templates": [template.id]}, headers=default_user_headers
-    )
-    assert response.status_code == 400
-
-
-@pytest.mark.parametrize("n_slot", [1, 5, 10])
-async def test_list_slot(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    n_slot: int,
-):
-    url = app.url_path_for("list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.get(url, headers=default_user_headers)
-    assert response.status_code == 200
-    assert len(response.json()) == 0
-    tag = session.get(SlotTag, default_tag_id)
-    assert tag is not None
-    for i in range(n_slot):
-        template = SlotTemplate(project_id=default_project_id, title=f"template {i}")
-        slot = Slot(
-            project_id=default_project_id,
-            title=f"Slot {i}",
-            starting_time=datetime(2024, 9, 9, 12, 2 * i),
-            ending_time=datetime(2024, 9, 9, 12, 2 * (i + 1)),
-        )
-        slot.template = template
-        tag.templates.append(template)
-        session.add_all([template, slot])
-
-    session.commit()
-    response = await client.get(url, headers=default_user_headers)
-    assert response.status_code == 200
-    assert len(response.json()) == n_slot
-
-
-async def test_delete_tag_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # no authent
-    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.delete(url)
-    assert response.status_code == 401
-    # invalid tag
-    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id="default_tag_id")
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 422
-
-    # invalid project_id
-    url = app.url_path_for("delete_tag", project_id="default_volunteer_id", tag_id=default_tag_id)
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 422
-
-
-async def test_delete_tag(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200
-    result = session.execute(select(SlotTag).where(SlotTag.id == default_tag_id))
-    slot = result.scalars().first()
-    assert slot is None
-
-    # can delete random uuid
-    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=uuid.uuid4())
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200
-
-
-async def test_delete_tag_idempotent(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Idempotence test
-    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
-    response = await client.delete(url, headers=default_user_headers)
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200

+ 409 - 0
app/tests/test_tags.py

@@ -0,0 +1,409 @@
+import uuid
+from datetime import datetime
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import OrgRole, Project, Slot, SlotTag, SlotTemplate
+from app.tests.conftest import default_organization_id, default_project_id, default_tag_id
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+ALL_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+route_kwargs = {"project_id": default_project_id}
+route_kwargs_2 = {**route_kwargs, "tag_id": default_tag_id}
+TAG_ROUTES = [
+    ("GET", "list_project_tags", route_kwargs, None),
+    ("POST", "create_tag", route_kwargs, {"title": "Test"}),
+    ("GET", "list_tagged_slot", route_kwargs_2, None),
+    ("POST", "update_tag", route_kwargs_2, {"title": "Updated"}),
+    ("DELETE", "delete_tag", route_kwargs_2, None),
+]
+
+
+class TestSlotCrossCuttingAccess(SharedProjectAccessTests):
+    @pytest.fixture(params=TAG_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+        return request.param
+
+
+class TestListProjectTags:
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_read(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == 200
+        assert len(response.json()) == 1
+
+        tag = SlotTag(title="1er tag", project_id=default_project_id)
+        session.add(tag)
+        session.commit()
+
+        response = await client.get(
+            app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == 200
+        assert len(response.json()) == 2
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.get(
+            app.url_path_for("list_project_tags", project_id=default_project_id), headers=headers
+        )
+        assert response.status_code == 403
+
+
+class TestCreateTag:
+    async def test_invalid_project_id_format(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_tag", project_id="default_project_id"), headers=headers
+        )
+        assert response.status_code == 422
+
+    async def test_invalid_payload(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=default_project_id),
+            json={"titl": "1st tag"},
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_create(
+        self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=default_project_id),
+            json={"title": "1st tag"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        tag = (
+            session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
+            .scalars()
+            .first()
+        )
+        assert tag is not None
+        assert tag.title == "1st tag"
+
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=default_project_id),
+            json={"title": "1st tag"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        tag_count = len(
+            session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
+            .scalars()
+            .all()
+        )
+        assert tag_count == 2
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=default_project_id),
+            json={"title": "1st tag"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_create_with_template(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        template = SlotTemplate(
+            project_id=default_project_id, title="coucou", description="ceci est une description"
+        )
+        session.add(template)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=default_project_id),
+            json={"title": "1st tag", "templates": [template.id]},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["templates_id"][0] == template.id
+        session.refresh(template)
+        assert len(template.tags) > 0
+
+    async def test_create_with_template_from_other_project_rejected(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        template = SlotTemplate(
+            project_id=default_project_id, title="coucou", description="ceci est une description"
+        )
+        session.add(template)
+        session.commit()
+
+        other_project = Project(
+            organization_id=default_organization_id, name="second project", is_public=False
+        )
+        session.add(other_project)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("create_tag", project_id=other_project.id),
+            json={"title": "1st tag", "templates": [template.id]},
+            headers=headers,
+        )
+        assert response.status_code == 400
+
+
+class TestUpdateTag:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json={"title": "royaux"})
+        assert response.status_code == 401
+
+    @pytest.mark.parametrize(
+        "payload", [{"title": [1.001, 2]}, {"title": {}}, {"templates": ["1", "2"]}]
+    )
+    async def test_invalid_payload(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, payload: dict
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json=payload, headers=headers)
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_update(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        tag = session.get(SlotTag, default_tag_id)
+        assert tag is not None
+
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json={"title": "new_title"}, headers=headers)
+        assert response.status_code == 200
+        session.refresh(tag)
+        assert tag.title == "new_title"
+        assert response.json()["title"] == "new_title"
+
+        template = SlotTemplate(project_id=default_project_id, title="template")
+        session.add(template)
+        session.commit()
+
+        response = await client.post(url, json={"templates": [template.id]}, headers=headers)
+        assert response.status_code == 200
+        session.refresh(tag)
+        assert len(tag.templates) == 1
+
+        response = await client.post(url, json={"templates": []}, headers=headers)
+        assert response.status_code == 200
+        session.refresh(tag)
+        assert len(tag.templates) == 0
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=None)
+        tag = session.get(SlotTag, default_tag_id)
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json={"title": "hijacked"}, headers=headers)
+        assert response.status_code == 403
+        session.refresh(tag)
+        assert tag.title != "hijacked"
+
+    async def test_template_from_other_project_rejected(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        tag = session.get(SlotTag, default_tag_id)
+        assert tag is not None
+
+        other_project = Project(
+            organization_id=default_organization_id, name="other project", is_public=False
+        )
+        template = SlotTemplate(title="template 1")
+        other_project.templates.append(template)
+        session.add(template)
+        session.add(other_project)
+        session.commit()
+
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json={"templates": [template.id]}, headers=headers)
+        assert response.status_code == 400
+
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user, session: Session
+    ):
+        _, headers = other_org_user
+        tag = session.get(SlotTag, default_tag_id)
+        url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.post(url, json={"title": "hijacked"}, headers=headers)
+        assert response.status_code == 403
+        session.refresh(tag)
+        assert tag.title != "hijacked"
+
+
+class TestListTaggedSlot:
+    @pytest.mark.parametrize("n_slot", [1, 5, 10])
+    async def test_returns_slots_for_tag(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        n_slot: int,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for(
+            "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
+        )
+
+        response = await client.get(url, headers=headers)
+        assert response.status_code == 200
+        assert len(response.json()) == 0
+
+        tag = session.get(SlotTag, default_tag_id)
+        assert tag is not None
+        for i in range(n_slot):
+            template = SlotTemplate(project_id=default_project_id, title=f"template {i}")
+            slot = Slot(
+                project_id=default_project_id,
+                title=f"Slot {i}",
+                starting_time=datetime(2024, 9, 9, 12, 2 * i),
+                ending_time=datetime(2024, 9, 9, 12, 2 * (i + 1)),
+            )
+            slot.template = template
+            tag.templates.append(template)
+            session.add_all([template, slot])
+        session.commit()
+
+        response = await client.get(url, headers=headers)
+        assert response.status_code == 200
+        assert len(response.json()) == n_slot
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        url = app.url_path_for(
+            "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
+        )
+        response = await client.get(url, headers=headers)
+        assert response.status_code == 403
+
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        url = app.url_path_for(
+            "list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id
+        )
+        response = await client.get(url, headers=headers)
+        assert response.status_code == 403
+
+
+class TestDeleteTag:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.delete(url)
+        assert response.status_code == 401
+
+    async def test_invalid_tag_id_format(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id="default_tag_id")
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 422
+
+    async def test_invalid_project_id_format(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for(
+            "delete_tag", project_id="default_volunteer_id", tag_id=default_tag_id
+        )
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 422
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=None)
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 403
+        assert session.get(SlotTag, default_tag_id) is not None
+
+    @pytest.mark.parametrize("role", ALL_ROLES)
+    async def test_all_roles_can_delete(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+        assert session.get(SlotTag, default_tag_id) is None
+
+    async def test_idempotent_delete(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+
+    async def test_delete_random_uuid(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        """tags.py still uses bulk DELETE with no existence check -- same
+        idempotent-200-on-missing pattern as volunteers.py/sms.py, not the
+        404-via-dependency-resolution used by slots.py/templates.py."""
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=uuid.uuid4())
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user, session: Session
+    ):
+        _, headers = other_org_user
+        url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 403
+        assert session.get(SlotTag, default_tag_id) is not None

+ 0 - 250
app/tests/test_template.py

@@ -1,250 +0,0 @@
-from uuid import uuid4
-from httpx import AsyncClient
-import pytest
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.main import app
-from app.models import Project, Slot, SlotTemplate
-from app.tests.conftest import default_slot_id, default_template_id, default_tag_id
-
-
-async def test_create_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    url = app.url_path_for("create_template", project_id=default_public_project.id)
-    response = await client.post(url, json={"title": "1st template"})
-    assert response.status_code == 401
-    response = await client.post(url, json={}, headers=default_user_headers)
-    assert response.status_code == 422
-
-    url = app.url_path_for("create_template", project_id=uuid4())
-    response = await client.post(url, json={"title": "1st template"}, headers=default_user_headers)
-    assert response.status_code == 404
-
-
-@pytest.mark.parametrize(
-    "payload",
-    [
-        {"title": "1st template"},
-        {"title": "1𝖘ҭ ṥ٥ι𝙪𝓉ìóη", "place": "echo"},
-        {"title": "1st template", "responsible_contact": "echo"},
-        {"title": "1st template", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "comment": "55"},
-    ],
-)
-async def test_create_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-):
-    url = app.url_path_for("create_template", project_id=default_public_project.id)
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, response.json()["id"])
-    assert template is not None
-    for k in ["title", "description", "place", "responsible_contact"]:
-        val = template.__getattribute__(k)
-        if k in payload:
-            assert val == payload[k]
-        else:
-            assert val == ""
-
-
-async def test_update_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    payload = {"title": "1st template"}
-    project = session.get(Project, default_public_project.id)
-    assert project is not None
-    template_id = project.templates[0].id
-    url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id="12"
-    )
-    response = await client.post(url, json=payload)
-    assert response.status_code == 401
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 422
-    url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id=uuid4()
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 404
-
-    url = app.url_path_for("update_template", project_id=uuid4(), template_id=template_id)
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 404
-
-
-@pytest.mark.parametrize(
-    "code,payload",
-    [
-        (422, {"title": [1.001, 2]}),
-        (422, {"responsible_contact": {"t": None}}),
-        (422, {"place": [1, 2]}),
-        (422, {"tags": ["1", "2"]}),
-        # (422, {"slots": "1"}),
-        (400, {"tags": [default_slot_id]}),
-        (422, {"comment": 1235}),
-    ],
-)
-async def test_update_template_fail_payload_validation(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-    code: int,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == code
-
-
-@pytest.mark.parametrize(
-    "payload",
-    [
-        {"title": "1st template"},
-        {"title": "1st template", "place": "echo"},
-        {"title": "1st template", "responsible_contact": "echo"},
-        {"title": "1st template", "description": "&é'(-è_çecho"},
-        {
-            "title": "1st template",
-            "place": "Ḽơᶉëᶆ ȋṕšᶙṁ",
-            "description": "&é'(-è_çecho",
-        },
-        {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "comment": "&é'(-è_çecho"},
-    ],
-)
-async def test_update_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, default_template_id)
-    assert template is not None
-    for k in ["title", "description", "place", "responsible_contact", "comment"]:
-        val = template.__getattribute__(k)
-        if k in payload:
-            assert val == payload[k]
-        else:
-            assert val == ""
-
-
-async def test_update_template_lists(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    payload = {"tags": [default_tag_id]}
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, default_template_id)
-    assert template is not None
-    assert len(template.tags) == 1
-    assert template.tags[0].id == default_tag_id
-    response = await client.post(url, json={"tags": []}, headers=default_user_headers)
-    assert response.status_code == 200
-    session.refresh(template)
-    assert len(template.tags) == 0
-
-
-async def test_delete_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    project_id = default_public_project.id
-    # no authent
-    url = app.url_path_for(
-        "delete_template", project_id=project_id, template_id=default_template_id
-    )
-    response = await client.delete(url)
-    assert response.status_code == 401
-    # invalid tag
-    url = app.url_path_for("delete_template", project_id=project_id, template_id="default_tag_id")
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 422
-
-    # invalid project_id
-    url = app.url_path_for("delete_template", project_id="ded", template_id=default_template_id)
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 422
-
-
-async def test_delete_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    url = app.url_path_for(
-        "delete_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200
-    result = session.execute(select(SlotTemplate).where(SlotTemplate.id == default_tag_id))
-    slot = result.scalars().first()
-    assert slot is None
-
-    # can delete random uuid
-    url = app.url_path_for("delete_tag", project_id=default_public_project.id, tag_id=uuid4())
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200
-
-
-async def test_delete_template_not_slot(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    url = app.url_path_for(
-        "delete_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    slot = session.get(Slot, default_slot_id)
-    assert slot is not None
-    slot.template_id = default_template_id
-    session.commit()
-
-    response = await client.delete(url, headers=default_user_headers)
-    assert response.status_code == 200
-    slot = session.get(Slot, default_slot_id)
-    assert slot is not None

+ 488 - 0
app/tests/test_templates.py

@@ -0,0 +1,488 @@
+from uuid import uuid4
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Commission, OrgRole, Project, Slot, SlotTemplate
+from app.tests.conftest import (
+    default_project_id,
+    default_slot_id,
+    default_tag_id,
+    default_template_id,
+)
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]  # respo_commission with NO commission set
+
+
+@pytest.fixture
+def commission_and_member(session: Session, default_public_project: Project, make_org_user):
+    """A commission in default_public_project, with a fresh respo_commission
+    user as its member. Returns (commission, member_user, member_headers)."""
+    commission = Commission(project_id=default_public_project.id, name="Bar")
+    session.add(commission)
+    session.commit()
+    user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
+    commission.members.append(user)
+    session.commit()
+    return commission, user, headers
+
+
+@pytest.fixture
+def other_commission(session: Session, default_public_project: Project):
+    """A second commission the respo_commission fixture user is NOT part of."""
+    commission = Commission(project_id=default_public_project.id, name="Scene")
+    session.add(commission)
+    session.commit()
+    return commission
+
+
+route_kwargs = {"project_id": default_project_id}
+route_kwargs_2 = {**route_kwargs, "template_id": default_template_id}
+TEMPLATE_ROUTES = [
+    ("GET", "list_project_templates", route_kwargs, None),
+    ("POST", "create_template", route_kwargs, {"title": "Test"}),
+    ("POST", "update_template", route_kwargs_2, {"title": "Updated"}),
+    ("DELETE", "delete_template", route_kwargs_2, None),
+]
+
+
+class TestSlotCrossCuttingAccess(SharedProjectAccessTests):
+    @pytest.fixture(params=TEMPLATE_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+        return request.param
+
+
+class TestListProjectTemplate:
+    async def test_ok(self, client: AsyncClient, default_public_project: Project, make_org_user):
+        _, headers = make_org_user(OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for("list_project_templates", project_id=default_public_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert len(response.json()) > 0
+
+
+class TestCreateTemplate:
+    async def test_validation_error(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            {"title": "1st template"},
+            {"title": "1𝖘ҭ ṥ٥ι𝙪𝓉ìóη", "place": "echo"},
+            {"title": "1st template", "responsible_override": "echo"},
+            {"title": "1st template", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "comment": "55"},
+        ],
+    )
+    async def test_org_admin_creates(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+        payload: dict,
+    ):
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        template = session.get(SlotTemplate, response.json()["id"])
+        assert template is not None
+        for k in ["title", "description", "place"]:
+            val = getattr(template, k)
+            assert val == payload[k] if k in payload else val == ""
+
+    async def test_respo_benevole_creates_without_commission(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "Benevole template"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    async def test_respo_commission_creates_for_own_commission(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+    ):
+        commission, _, headers = commission_and_member
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "Bar setup", "commission_id": commission.id},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["commission_id"] == commission.id
+
+    async def test_respo_commission_rejected_for_other_commission(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission,
+    ):
+        _, _, headers = commission_and_member
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "Scene setup", "commission_id": other_commission.id},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_respo_ok(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "No commission"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+
+class TestUpdateTemplate:
+    async def test_invalid_template_id_format(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=default_public_project.id, template_id="12"
+            ),
+            json={"title": "1st template"},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_template_not_found(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=default_public_project.id, template_id=uuid4()
+            ),
+            json={"title": "1st template"},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.parametrize(
+        "code,payload",
+        [
+            (422, {"title": [1.001, 2]}),
+            (422, {"responsible_override": {"t": None}}),
+            (422, {"place": [1, 2]}),
+            (422, {"tags": ["1", "2"]}),
+            (400, {"tags": [default_slot_id]}),
+            (422, {"comment": 1235}),
+        ],
+    )
+    async def test_payload_validation(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        payload: dict,
+        code: int,
+    ):
+        response = await client.post(
+            app.url_path_for(
+                "update_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == code
+
+    @pytest.mark.parametrize(
+        "payload",
+        [
+            {"title": "1st template"},
+            {"title": "1st template", "place": "echo"},
+            {"title": "1st template", "responsible_override": "echo"},
+            {"title": "1st template", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "place": "Ḽơᶉëᶆ ȋṕšᶙṁ", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "comment": "&é'(-è_çecho"},
+        ],
+    )
+    async def test_org_admin_updates(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+        payload: dict,
+    ):
+        response = await client.post(
+            app.url_path_for(
+                "update_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        template = session.get(SlotTemplate, default_template_id)
+        assert template is not None
+        for k in ["title", "description", "place", "comment"]:
+            val = getattr(template, k)
+            assert val == payload[k] if k in payload else val == ""
+
+    async def test_update_tags(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        url = app.url_path_for(
+            "update_template", project_id=default_public_project.id, template_id=default_template_id
+        )
+        response = await client.post(
+            url, json={"tags": [default_tag_id]}, headers=default_user_headers
+        )
+        assert response.status_code == 200
+        template = session.get(SlotTemplate, default_template_id)
+        assert len(template.tags) == 1
+        assert template.tags[0].id == default_tag_id
+
+        response = await client.post(url, json={"tags": []}, headers=default_user_headers)
+        assert response.status_code == 200
+        session.refresh(template)
+        assert len(template.tags) == 0
+
+    async def test_respo_commission_updates_own_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        session: Session,
+    ):
+        commission, _, headers = commission_and_member
+        template = SlotTemplate(
+            project_id=default_public_project.id, title="Bar", commission_id=commission.id
+        )
+        session.add(template)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=default_public_project.id, template_id=template.id
+            ),
+            json={"title": "Bar renamed"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+        session.refresh(template)
+        assert template.title == "Bar renamed"
+
+    async def test_respo_commission_rejected_for_other_commission_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        template = SlotTemplate(
+            project_id=default_public_project.id, title="Scene", commission_id=other_commission.id
+        )
+        session.add(template)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=default_public_project.id, template_id=template.id
+            ),
+            json={"title": "Hijacked"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_respo_commission_reassign_to_other_commission_rejected(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission,
+        session: Session,
+    ):
+        commission, _, headers = commission_and_member
+        template = SlotTemplate(
+            project_id=default_public_project.id, title="Bar", commission_id=commission.id
+        )
+        session.add(template)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=default_public_project.id, template_id=template.id
+            ),
+            json={"commission_id": other_commission.id},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+
+class TestDeleteTemplate:
+    async def test_invalid_ids(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template",
+                project_id=default_public_project.id,
+                template_id="default_tag_id",
+            ),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+        response = await client.delete(
+            app.url_path_for("delete_template", project_id="ded", template_id=default_template_id),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_org_admin_deletes(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert session.get(SlotTemplate, default_template_id) is None
+
+    async def test_delete_does_not_delete_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        default_user_headers: dict,
+        default_user_org_membership,
+        session: Session,
+    ):
+        slot = session.get(Slot, default_slot_id)
+        slot.template_id = default_template_id
+        session.commit()
+
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            headers=default_user_headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Slot, default_slot_id) is not None
+
+    async def test_respo_commission_deletes_own_template(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        session: Session,
+    ):
+        commission, _, headers = commission_and_member
+        template = SlotTemplate(
+            project_id=default_public_project.id, title="Bar", commission_id=commission.id
+        )
+        session.add(template)
+        session.commit()
+        template_id = template.id
+        session.expunge(template)
+
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template", project_id=default_public_project.id, template_id=template_id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(SlotTemplate, template_id) is None
+
+    async def test_respo_commission_rejected_for_other_commission(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        commission_and_member,
+        other_commission,
+        session: Session,
+    ):
+        _, _, headers = commission_and_member
+        template = SlotTemplate(
+            project_id=default_public_project.id, title="Scene", commission_id=other_commission.id
+        )
+        session.add(template)
+        session.commit()
+
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template", project_id=default_public_project.id, template_id=template.id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 403
+        assert session.get(SlotTemplate, template.id) is not None

+ 241 - 55
app/tests/test_users.py

@@ -1,60 +1,246 @@
+from uuid import uuid4
+
+import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import User
-from app.tests.conftest import (
-    default_user_email,
-    default_user_id,
-    default_user_password_hash,
-)
-
-
-async def test_read_current_user(client: AsyncClient, default_user_headers):
-    response = await client.get(app.url_path_for("read_current_user"), headers=default_user_headers)
-    assert response.status_code == 200
-    assert response.json() == {
-        "id": default_user_id,
-        "email": default_user_email,
-    }
-
-
-async def test_delete_current_user(client: AsyncClient, default_user_headers, session: Session):
-    response = await client.delete(
-        app.url_path_for("delete_current_user"), headers=default_user_headers
-    )
-    assert response.status_code == 204
-    result = session.execute(select(User).where(User.id == default_user_id))
-    user = result.scalars().first()
-    assert user is None
-
-
-async def test_reset_current_user_password(
-    client: AsyncClient, default_user_headers, session: Session
-):
-    response = await client.post(
-        app.url_path_for("reset_current_user_password"),
-        headers=default_user_headers,
-        json={"password": "testxxxxxx"},
-    )
-    assert response.status_code == 200
-    result = session.execute(select(User).where(User.id == default_user_id))
-    user = result.scalars().first()
-    assert user is not None
-    assert user.hashed_password != default_user_password_hash
-
-
-async def test_register_new_user(client: AsyncClient, default_user_headers, session: Session):
-    response = await client.post(
-        app.url_path_for("register_new_user"),
-        headers=default_user_headers,
-        json={
-            "email": "qwe@example.com",
-            "password": "asdasdasd",
-        },
-    )
-    assert response.status_code == 200
-    result = session.execute(select(User).where(User.email == "qwe@example.com"))
-    user = result.scalars().first()
-    assert user is not None
+from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
+from app.tests.conftest import default_organization_id, default_user_email, default_user_password
+
+pytestmark = pytest.mark.asyncio
+
+
+class TestListUsers:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.get(app.url_path_for("list_users"))
+        assert response.status_code == 401
+
+    async def test_sees_colleagues_in_same_organization(self, client: AsyncClient, make_org_user):
+        viewer, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        colleague, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)  # same default_organization
+
+        response = await client.get(app.url_path_for("list_users"), headers=headers)
+        assert response.status_code == 200
+        emails = [u["email"] for u in response.json()]
+        assert viewer.email in emails
+        assert colleague.email in emails
+
+    async def test_excludes_users_from_other_organization(
+        self, client: AsyncClient, make_org_user, session: Session
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)  # member of default_organization
+
+        other_org = Organization(id=uuid4(), name="Other Org")
+        session.add(other_org)
+        session.commit()
+        outsider = User(id=uuid4(), email="outsider@test.com", hashed_password="x", name="Outsider")
+        session.add(outsider)
+        session.commit()
+        session.add(
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN
+            )
+        )
+        session.commit()
+
+        response = await client.get(app.url_path_for("list_users"), headers=headers)
+        assert response.status_code == 200
+        emails = [u["email"] for u in response.json()]
+        assert outsider.email not in emails
+
+    async def test_user_with_no_organization_sees_only_self(
+        self, client: AsyncClient, make_org_user
+    ):
+        user, headers = make_org_user(role=None)
+        response = await client.get(app.url_path_for("list_users"), headers=headers)
+        assert response.status_code == 200
+        emails = [u["email"] for u in response.json()]
+        assert emails == [user.email]
+
+    async def test_super_admin_sees_users_across_all_organizations(
+        self, client: AsyncClient, make_org_user, session: Session
+    ):
+        member, _ = make_org_user(role=OrgRole.ORG_ADMIN)  # in default_organization
+
+        other_org = Organization(id=uuid4(), name="Other Org")
+        session.add(other_org)
+        session.commit()
+        outsider = User(
+            id=uuid4(), email="outsider2@test.com", hashed_password="x", name="Outsider2"
+        )
+        session.add(outsider)
+        session.commit()
+        session.add(
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.RESPO_BENEVOLE
+            )
+        )
+        session.commit()
+
+        _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(app.url_path_for("list_users"), headers=admin_headers)
+        assert response.status_code == 200
+        emails = [u["email"] for u in response.json()]
+        assert member.email in emails
+        assert outsider.email in emails
+
+
+class TestReadCurrentUser:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.get(app.url_path_for("read_current_user"))
+        assert response.status_code == 401
+
+    async def test_returns_caller(self, client: AsyncClient, default_user_headers: dict):
+        response = await client.get(
+            app.url_path_for("read_current_user"), headers=default_user_headers
+        )
+        assert response.status_code == 200
+        assert response.json()["email"] == default_user_email
+
+    async def test_includes_org_memberships(self, client: AsyncClient, make_org_user):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.get(app.url_path_for("read_current_user"), headers=headers)
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["organizations"]) == 1
+        assert data["organizations"][0]["role"] == OrgRole.RESPO_BENEVOLE.value
+
+
+class TestUpdateCurrentUserProfile:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.post(
+            app.url_path_for("update_current_user_profile"), json={"name": "New Name"}
+        )
+        assert response.status_code == 401
+
+    async def test_updates_name(
+        self, client: AsyncClient, default_user_headers: dict, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("update_current_user_profile"),
+            headers=default_user_headers,
+            json={"name": "Geralt of Rivia"},
+        )
+        assert response.status_code == 200
+        assert response.json()["name"] == "Geralt of Rivia"
+
+    async def test_updates_phone_number(self, client: AsyncClient, default_user_headers: dict):
+        response = await client.post(
+            app.url_path_for("update_current_user_profile"),
+            headers=default_user_headers,
+            json={"phone_number": "0601020304"},
+        )
+        assert response.status_code == 200
+        assert response.json()["phone_number"] == "0601020304"
+
+    async def test_partial_update_does_not_clear_other_field(
+        self, client: AsyncClient, default_user_headers: dict
+    ):
+        await client.post(
+            app.url_path_for("update_current_user_profile"),
+            headers=default_user_headers,
+            json={"name": "Geralt", "phone_number": "0601020304"},
+        )
+        response = await client.post(
+            app.url_path_for("update_current_user_profile"),
+            headers=default_user_headers,
+            json={"name": "Geralt Updated"},
+        )
+        assert response.status_code == 200
+        assert response.json()["name"] == "Geralt Updated"
+        assert response.json()["phone_number"] == "0601020304"
+
+
+class TestDeleteCurrentUser:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.delete(app.url_path_for("delete_current_user"))
+        assert response.status_code == 401
+
+    async def test_deletes_caller(self, client: AsyncClient, make_org_user, session: Session):
+        user, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.delete(app.url_path_for("delete_current_user"), headers=headers)
+        assert response.status_code == 204
+        assert session.get(User, user.id) is None
+
+    async def test_cascades_org_membership(
+        self, client: AsyncClient, make_org_user, session: Session
+    ):
+        """Deleting a user should also remove their UserOrganization rows (FK cascade)."""
+        from app.models import UserOrganization
+
+        user, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        assert session.get(UserOrganization, (user.id, default_organization_id)) is not None
+
+        await client.delete(app.url_path_for("delete_current_user"), headers=headers)
+
+        result = session.execute(
+            select(UserOrganization).where(UserOrganization.user_id == user.id)
+        )
+        assert result.scalars().first() is None
+
+
+class TestResetPassword:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.post(
+            app.url_path_for("reset_current_user_password"), json={"password": "new-password"}
+        )
+        assert response.status_code == 401
+
+    async def test_updates_password(
+        self, client: AsyncClient, default_user_headers: dict, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("reset_current_user_password"),
+            headers=default_user_headers,
+            json={"password": "new-password"},
+        )
+        assert response.status_code == 200
+
+        from app.core.security import verify_password
+
+        result = session.execute(select(User).where(User.email == default_user_email))
+        user = result.scalars().first()
+        assert verify_password("new-password", user.hashed_password)
+        assert not verify_password(default_user_password, user.hashed_password)
+
+
+class TestRegisterUser:
+    async def test_creates_user(self, client: AsyncClient, session: Session):
+        response = await client.post(
+            app.url_path_for("register_new_user"),
+            json={"email": "new-recruit@test.com", "password": "hunter2", "name": "New Recruit"},
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["email"] == "new-recruit@test.com"
+        assert data["name"] == "New Recruit"
+
+        result = session.execute(select(User).where(User.email == "new-recruit@test.com"))
+        user = result.scalars().first()
+        assert user is not None
+        assert user.global_role == GlobalRole.USER
+        assert user.organizations == []
+
+    async def test_no_auth_required(self, client: AsyncClient):
+        """Registration is public -- no headers needed."""
+        response = await client.post(
+            app.url_path_for("register_new_user"),
+            json={"email": "another@test.com", "password": "hunter2", "name": "Another"},
+        )
+        assert response.status_code == 200
+
+    async def test_duplicate_email_rejected(self, client: AsyncClient, default_user):
+        response = await client.post(
+            app.url_path_for("register_new_user"),
+            json={"email": default_user_email, "password": "hunter2", "name": "Impostor"},
+        )
+        assert response.status_code == 400
+
+    async def test_missing_name_validation_error(self, client: AsyncClient):
+        response = await client.post(
+            app.url_path_for("register_new_user"),
+            json={"email": "no-name@test.com", "password": "hunter2"},
+        )
+        assert response.status_code == 422

+ 0 - 357
app/tests/test_volunteer.py

@@ -1,357 +0,0 @@
-import uuid
-from httpx import AsyncClient
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.main import app
-from app.models import Project, Slot, Sms, Volunteer
-from app.tests.conftest import (
-    default_project_id,
-    default_volunteer_id,
-    default_slot_id,
-)
-
-
-async def test_read_list_project_volunteers(
-    client: AsyncClient, default_user_headers: dict, default_public_project: Project
-):
-    response = await client.get(
-        app.url_path_for("list_project_volunteers", project_id=default_project_id),
-    )
-    assert response.status_code == 401
-    response = await client.get(
-        app.url_path_for("list_project_volunteers", project_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-    response = await client.get(
-        app.url_path_for("list_project_volunteers", project_id="pas un uuid valid"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    response = await client.get(
-        app.url_path_for("list_project_volunteers", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-    volunteer_response = data[0]
-    assert volunteer_response["name"] == "Arthur"
-    assert volunteer_response["id"] == default_volunteer_id
-    assert "created_at" in volunteer_response
-
-
-async def test_create_volunteer(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=default_project_id)
-    )
-    assert response.status_code == 401
-    payload = {
-        "name": "Lancelot",
-        "email": "lancelot@dulac.fr",
-        "phone_number": "03 14 15 92 65",
-    }
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=uuid.uuid4()),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["id"] != default_project_id
-    assert response.json()["name"] == "Lancelot"
-    assert response.json()["comment"] == ""
-    result = session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
-    volunteers = result.scalars().all()
-    assert len(volunteers) > 1
-
-    # test invalid payload
-    del payload["name"]
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_create_volunteer_comment(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    payload = {
-        "name": "Lancelot",
-        "email": "lancelot@dulac.fr",
-        "phone_number": "03 14 15 92 65",
-        "comment": "it's a knight",
-    }
-    # Test normal payload
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["id"] != default_project_id
-    assert response.json()["name"] == "Lancelot"
-    assert response.json()["comment"] != ""
-    new_id = response.json()["id"]
-    result = session.execute(select(Volunteer).where(Volunteer.id == new_id))
-    volunteer = result.scalar_one_or_none()
-    assert volunteer is not None
-    assert volunteer.comment == "it's a knight"
-
-
-async def test_update_volunteer(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        )
-    )
-    assert response.status_code == 401
-
-    payload = {
-        "name": "Lancelot",
-        "email": "lancelot@dulac.fr",
-        "phone_number": "03 14 15 92 65",
-        "automatic_sms": False,
-        "comment": "new comment",
-    }
-
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=uuid.uuid4(),
-            volunteer_id=default_volunteer_id,
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # test invalid volunteer_id
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=uuid.uuid4(),
-        ),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-
-    for k, v in payload.items():
-        response = await client.post(
-            app.url_path_for(
-                "update_volunteer",
-                project_id=default_project_id,
-                volunteer_id=default_volunteer_id,
-            ),
-            json={k: v},
-            headers=default_user_headers,
-        )
-        assert response.status_code == 200
-        assert response.json()["id"] == default_volunteer_id
-        assert response.json()[k] == v
-
-
-async def test_update_volunteer_slots(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        json={"slots": []},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 200
-    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
-    slot = result.scalars().first()
-    assert slot is not None
-    assert slot.volunteers_id == []
-
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        json={"slots": [default_slot_id]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 200
-    session.refresh(slot)
-    assert slot is not None
-    assert slot.volunteers_id == [default_volunteer_id]
-
-    # An invalid slot list
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        json={"slots": [str(uuid.uuid4())]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 400
-
-    # An invalid slot list
-    response = await client.post(
-        app.url_path_for(
-            "update_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        json={"slots": ["not uuid str"]},
-        headers=default_user_headers,
-    )
-
-    assert response.status_code == 422
-
-
-async def test_delete_volunteer(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail deleting the project due to not logged in
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        )
-    )
-    assert response.status_code == 401
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer = result.scalars().first()
-    assert volunteer is not None
-
-    # Proper deletion
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    volunteer = result.scalars().first()
-    assert volunteer is None
-
-    # check deletion is cascaded to slots
-    result = session.execute(select(Slot).where(Slot.id == default_slot_id))
-    slot: Slot | None = result.scalar_one_or_none()
-    assert slot is not None
-    assert default_volunteer_id not in slot.volunteers_id
-
-    # Idempotence test
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-
-async def test_delete_volunteer_any_uuid(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # can delete random uuid
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
-    # Cannot delete non uuid string
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer", project_id=default_project_id, volunteer_id="not uidstr"
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_delete_volunteer_with_sms(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    sms = Sms()
-    sms.project_id = default_project_id
-    sms.content = "coucou"
-    sms.phone_number = "02 66 66 66 66 66"
-    sms.volunteer_id = default_volunteer_id
-    session.add(sms)
-    session.commit()
-    session.refresh(sms)
-
-    response = await client.delete(
-        app.url_path_for(
-            "delete_volunteer",
-            project_id=default_project_id,
-            volunteer_id=default_volunteer_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    # Volunteer must be deleted
-    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
-    assert result.scalar_one_or_none() is None
-
-    # Sms must be deleted
-    assert session.execute(select(Sms).where(Sms.id == sms.id)).scalar_one_or_none() is None

+ 418 - 0
app/tests/test_volunteer_groups.py

@@ -0,0 +1,418 @@
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.session import session as session_maker
+from app.main import app
+from app.models import Organization, OrgRole, Project, Volunteer, VolunteerGroup
+from app.tests.conftest import default_project_id, default_slot_id
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+MANAGE_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE]
+FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
+
+
+@pytest.fixture
+def two_volunteers(default_project: Project) -> tuple[Volunteer, Volunteer]:
+    with session_maker() as db:
+        v1 = Volunteer(
+            project_id=default_project.id,
+            name="Yvain",
+            surname="Lion",
+            email="y@test.com",
+            phone_number="0601010101",
+            automatic_sms=True,
+        )
+        v2 = Volunteer(
+            project_id=default_project.id,
+            name="Lancelot",
+            surname="Lac",
+            email="l@test.com",
+            phone_number="0602020202",
+            automatic_sms=True,
+        )
+        db.add_all([v1, v2])
+        db.commit()
+        db.refresh(v1)
+        db.refresh(v2)
+        return v1, v2
+
+
+@pytest.fixture
+def default_group(default_project: Project) -> VolunteerGroup:
+    with session_maker() as db:
+        group = VolunteerGroup(project_id=default_project.id, name="Chevaliers", color="#FF0000")
+        db.add(group)
+        db.commit()
+        db.refresh(group)
+        return group
+
+
+route_kwargs = {"project_id": default_project_id}
+route_kwargs_2 = {**route_kwargs, "group_id": "GROUP"}
+VOLUNTEER_GROUP_ROUTES = [
+    ("GET", "list_project_groups", route_kwargs, None),
+    ("POST", "create_group", route_kwargs, {"name": "Test"}),
+    ("GET", "get_group", route_kwargs_2, None),
+    ("POST", "update_group", route_kwargs_2, {"name": "Updated"}),
+    ("DELETE", "delete_group", route_kwargs_2, None),
+    ("POST", "add_volunteers_to_group", route_kwargs_2, {"volunteer_ids": []}),
+    ("DELETE", "remove_volunteer_from_group", {**route_kwargs_2, "volunteer_id": "VOL"}, None),
+    ("POST", "send_sms_to_group", route_kwargs_2, {"content": "coucou"}),
+]
+
+
+class TestVolunteerGroupCrossCuttingAccess(SharedProjectAccessTests):
+    @pytest.fixture(params=VOLUNTEER_GROUP_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_group, two_volunteers):
+        method, endpoint, kwargs, payload = request.param
+        resolved_kwargs = {
+            k: (
+                default_group.id
+                if v == "GROUP"
+                else two_volunteers[0].id
+                if v == "VOL"
+                else default_slot_id
+                if v == "SLOT"
+                else v
+            )
+            for k, v in kwargs.items()
+        }
+        return method, endpoint, resolved_kwargs, payload
+
+
+class TestListGroups:
+    @pytest.mark.parametrize("role", MANAGE_ROLES)
+    async def test_role_access(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_groups", project_id=default_project.id), headers=headers
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["name"] == "Chevaliers"
+
+    @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
+    async def test_forbidden_roles(
+        self, client: AsyncClient, default_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_groups", project_id=default_project.id), headers=headers
+        )
+        assert response.status_code == 403
+
+    async def test_project_not_found(self, client: AsyncClient, make_org_user):
+        _, headers = (
+            make_org_user(role=OrgRole.ORG_ADMIN, global_role=None)
+            if False
+            else make_org_user(role=None)
+        )
+        # super_admin needed since require_org_role 404s on missing project regardless of role
+        from app.models import GlobalRole
+
+        _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
+        response = await client.get(
+            app.url_path_for("list_project_groups", project_id=uuid.uuid4()), headers=headers
+        )
+        assert response.status_code == 404
+
+
+class TestCreateGroup:
+    @pytest.mark.parametrize("role", MANAGE_ROLES)
+    async def test_role_access(
+        self, client: AsyncClient, default_project: Project, make_org_user, session: Session, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_group", project_id=default_project.id),
+            headers=headers,
+            json={"name": "Bar team", "color": "#00FF00"},
+        )
+        assert response.status_code == 200
+        group = (
+            session.execute(select(VolunteerGroup).where(VolunteerGroup.name == "Bar team"))
+            .scalars()
+            .first()
+        )
+        assert group is not None
+        assert group.color == "#00FF00"
+
+    @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
+    async def test_forbidden_roles(
+        self, client: AsyncClient, default_project: Project, make_org_user, role
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for("create_group", project_id=default_project.id),
+            headers=headers,
+            json={"name": "Nope"},
+        )
+        assert response.status_code == 403
+
+
+class TestGetGroup:
+    async def test_not_found(self, client: AsyncClient, make_org_user, default_project: Project):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.get(
+            app.url_path_for("get_group", project_id=default_project.id, group_id=uuid.uuid4()),
+            headers=headers,
+        )
+        assert response.status_code == 404
+
+    async def test_group_from_other_project_not_found(
+        self, client: AsyncClient, default_group: VolunteerGroup, make_org_user, session: Session
+    ):
+        """A group belonging to a different project must 404, even for a
+        role that would otherwise pass, since group.project_id doesn't
+        match the project_id in the URL."""
+        other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
+        session.add(other_org)
+        session.commit()
+        other_project = Project(name="Other Project", is_public=False, organization_id=other_org.id)
+        session.add(other_project)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)  # member of default_organization
+        response = await client.get(
+            app.url_path_for("get_group", project_id=other_project.id, group_id=default_group.id),
+            headers=headers,
+        )
+        # 403 (not a member of other_project's org) fires before the group-mismatch
+        # check even runs -- both are valid rejections, but confirm which one applies
+        assert response.status_code in (403, 404)
+
+
+class TestUpdateGroup:
+    async def test_updates_name_and_color(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "update_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"name": "Renamed", "color": "#0000FF"},
+        )
+        assert response.status_code == 200
+        group = session.get(VolunteerGroup, default_group.id)
+        assert group.name == "Renamed"
+        assert group.color == "#0000FF"
+
+
+class TestDeleteGroup:
+    async def test_deletes_group_without_deleting_volunteers(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+        session: Session,
+    ):
+        v1, _ = two_volunteers
+        group = session.get(VolunteerGroup, default_group.id)
+        volunteer = session.get(Volunteer, v1.id)
+        group.volunteers.append(volunteer)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(VolunteerGroup, default_group.id) is None
+        assert session.get(Volunteer, v1.id) is not None
+
+
+class TestGroupMembership:
+    async def test_add_volunteers_to_group(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+    ):
+        v1, v2 = two_volunteers
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"volunteer_ids": [v1.id, v2.id]},
+        )
+        assert response.status_code == 200
+        assert sorted(response.json()["volunteers_id"]) == sorted([v1.id, v2.id])
+
+    async def test_add_is_idempotent(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+    ):
+        v1, _ = two_volunteers
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        await client.post(
+            app.url_path_for(
+                "add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"volunteer_ids": [v1.id]},
+        )
+        response = await client.post(
+            app.url_path_for(
+                "add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"volunteer_ids": [v1.id]},
+        )
+        assert response.status_code == 200
+        assert response.json()["volunteers_id"] == [v1.id]
+
+    async def test_volunteer_from_other_project_rejected(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        make_org_user,
+        session: Session,
+    ):
+        other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
+        session.add(other_org)
+        session.commit()
+        other_project = Project(
+            name="Other Project 2", is_public=False, organization_id=other_org.id
+        )
+        session.add(other_project)
+        session.commit()
+        stray_volunteer = Volunteer(
+            project_id=other_project.id,
+            name="Stray",
+            surname="Cat",
+            email="s@test.com",
+            phone_number="0600000000",
+        )
+        session.add(stray_volunteer)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "add_volunteers_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"volunteer_ids": [stray_volunteer.id]},
+        )
+        assert response.status_code == 400
+
+    async def test_remove_volunteer_from_group(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+        session: Session,
+    ):
+        v1, v2 = two_volunteers
+        group = session.get(VolunteerGroup, default_group.id)
+        group.volunteers.append(session.get(Volunteer, v1.id))
+        group.volunteers.append(session.get(Volunteer, v2.id))
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.delete(
+            app.url_path_for(
+                "remove_volunteer_from_group",
+                project_id=default_project.id,
+                group_id=default_group.id,
+                volunteer_id=v1.id,
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["volunteers_id"] == [v2.id]
+
+
+class TestSendSmsToGroup:
+    async def test_sends_to_each_member_with_automatic_sms(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+        session: Session,
+    ):
+        v1, v2 = two_volunteers
+        group = session.get(VolunteerGroup, default_group.id)
+        group.volunteers.append(session.get(Volunteer, v1.id))
+        group.volunteers.append(session.get(Volunteer, v2.id))
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "send_sms_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"content": "Bonjour {prenom} {nom} !"},
+        )
+        assert response.status_code == 200
+        contents = sorted(sms["content"] for sms in response.json())
+        assert contents == ["Bonjour Lancelot Lac !", "Bonjour Yvain Lion !"]
+
+    async def test_skips_volunteers_without_automatic_sms(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        two_volunteers,
+        make_org_user,
+        session: Session,
+    ):
+        v1, v2 = two_volunteers
+        vol2 = session.get(Volunteer, v2.id)
+        vol2.automatic_sms = False
+        group = session.get(VolunteerGroup, default_group.id)
+        group.volunteers.append(session.get(Volunteer, v1.id))
+        group.volunteers.append(session.get(Volunteer, v2.id))
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "send_sms_to_group", project_id=default_project.id, group_id=default_group.id
+            ),
+            headers=headers,
+            json={"content": "Bonjour {prenom} !"},
+        )
+        assert response.status_code == 200
+        assert len(response.json()) == 1
+        assert response.json()[0]["content"] == "Bonjour Yvain !"

+ 406 - 0
app/tests/test_volunteers.py

@@ -0,0 +1,406 @@
+import uuid
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import OrgRole, Project, Slot, Sms, Volunteer
+from app.tests.conftest import default_project_id, default_slot_id, default_volunteer_id
+from app.tests.shared_access import SharedProjectAccessTests
+
+pytestmark = pytest.mark.asyncio
+
+WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
+
+
+route_kwargs = {"project_id": default_project_id}
+route_kwargs_2 = {**route_kwargs, "volunteer_id": default_volunteer_id}
+VOLUNTEER_ROUTES = [
+    ("GET", "list_project_volunteers", route_kwargs, None),
+    ("POST", "create_volunteer", route_kwargs, {"name": "Test", "email": "a@free.fr"}),
+    ("POST", "update_volunteer", route_kwargs_2, {"name": "Updated"}),
+    ("DELETE", "delete_volunteer", route_kwargs_2, None),
+]
+
+
+class TestVolunteerCrossCuttingAccess(SharedProjectAccessTests):
+    @pytest.fixture(params=VOLUNTEER_ROUTES, ids=lambda x: f"{x[0]}-{x[1]}")
+    def resolved_route(self, request, default_project):
+        return request.param
+
+
+class TestListVolunteer:
+    @pytest.mark.parametrize(
+        "role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+    )
+    async def test_readable_by_all_org_roles(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        role,
+    ):
+        """respo_commission can read volunteers -- needs visibility into who
+        they'll be working with -- even though they can't manage them."""
+        _, headers = make_org_user(role=role)
+        response = await client.get(
+            app.url_path_for("list_project_volunteers", project_id=default_project_id),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["name"] == "Arthur"
+        assert data[0]["id"] == default_volunteer_id
+        assert "created_at" in data[0]
+
+    async def test_no_membership_forbidden(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=None)
+        response = await client.get(
+            app.url_path_for("list_project_volunteers", project_id=default_project_id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+
+class TestCreateVolunteer:
+    async def test_invalid_payload(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {"email": "lancelot@dulac.fr", "phone_number": "03 14 15 92 65"}
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
+    async def test_write_roles_create(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        payload = {
+            "name": "Lancelot",
+            "email": "lancelot@dulac.fr",
+            "phone_number": "03 14 15 92 65",
+        }
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["id"] != default_project_id
+        assert response.json()["name"] == "Lancelot"
+        assert response.json()["comment"] == ""
+
+        volunteers = (
+            session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
+            .scalars()
+            .all()
+        )
+        assert len(volunteers) > 1
+
+    @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
+    async def test_read_only_roles_forbidden(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        payload = {
+            "name": "Lancelot",
+            "email": "lancelot@dulac.fr",
+            "phone_number": "03 14 15 92 65",
+        }
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_create_with_slots(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {
+            "name": "Lancelot",
+            "email": "lancelot@dulac.fr",
+            "phone_number": "03 14 15 92 65",
+            "slots": [default_slot_id],
+        }
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 200
+        volunteers = (
+            session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
+            .scalars()
+            .all()
+        )
+        new_volunteer = [v for v in volunteers if v.id != default_volunteer_id][0]
+        assert len(new_volunteer.slots) == 1
+
+    async def test_create_with_comment(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {
+            "name": "Lancelot",
+            "email": "lancelot@dulac.fr",
+            "phone_number": "03 14 15 92 65",
+            "comment": "it's a knight",
+        }
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert response.json()["comment"] != ""
+        volunteer = session.get(Volunteer, response.json()["id"])
+        assert volunteer.comment == "it's a knight"
+
+
+class TestUpdateVolunteer:
+    async def test_invalid_volunteer_id(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {"name": "Lancelot", "email": "l@dulac.fr", "phone_number": "0314159265"}
+        response = await client.post(
+            app.url_path_for(
+                "update_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
+            ),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.parametrize("role", [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE])
+    async def test_write_roles_update_each_field(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        payload = {
+            "name": "Lancelot",
+            "email": "lancelot@dulac.fr",
+            "phone_number": "03 14 15 92 65",
+            "automatic_sms": False,
+            "comment": "new comment",
+        }
+        for k, v in payload.items():
+            response = await client.post(
+                app.url_path_for(
+                    "update_volunteer",
+                    project_id=default_project_id,
+                    volunteer_id=default_volunteer_id,
+                ),
+                json={k: v},
+                headers=headers,
+            )
+            assert response.status_code == 200
+            assert response.json()["id"] == default_volunteer_id
+            assert response.json()[k] == v
+
+    @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
+    async def test_read_only_roles_forbidden(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "update_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+            ),
+            json={"comment": "hijacked"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_update_slots(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for(
+            "update_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+        )
+
+        response = await client.post(url, json={"slots": []}, headers=headers)
+        assert response.status_code == 200
+        slot = session.execute(select(Slot).where(Slot.id == default_slot_id)).scalars().first()
+        assert slot.volunteers_id == []
+
+        response = await client.post(url, json={"slots": [default_slot_id]}, headers=headers)
+        assert response.status_code == 200
+        session.refresh(slot)
+        assert slot.volunteers_id == [default_volunteer_id]
+
+        response = await client.post(url, json={"slots": [str(uuid.uuid4())]}, headers=headers)
+        assert response.status_code == 400
+
+        response = await client.post(url, json={"slots": ["not uuid str"]}, headers=headers)
+        assert response.status_code == 422
+
+
+class TestDeleteVolunteer:
+    @pytest.mark.parametrize("role", WRITE_FORBIDDEN_ROLES)
+    async def test_read_only_roles_forbidden(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 403
+        assert session.get(Volunteer, default_volunteer_id) is not None
+
+    async def test_org_admin_deletes(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Volunteer, default_volunteer_id) is None
+
+        slot = session.execute(select(Slot).where(Slot.id == default_slot_id)).scalars().first()
+        assert default_volunteer_id not in slot.volunteers_id
+
+    async def test_idempotent_delete(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        url = app.url_path_for(
+            "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+        )
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+        response = await client.delete(url, headers=headers)
+        assert response.status_code == 200
+
+    async def test_delete_random_uuid(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        """Unlike delete_slot/delete_template, this still bulk-DELETEs
+        without an existence check via a Depends() resolver -- so a
+        nonexistent volunteer_id stays a silent 200, matching the original
+        behavior. If you want 404-on-missing consistency across all three
+        routers, this needs the same get()-then-check pattern slots.py uses."""
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_volunteer", project_id=default_project_id, volunteer_id=uuid.uuid4()
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    async def test_invalid_uuid_format(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for(
+                "delete_volunteer", project_id=default_project_id, volunteer_id="not uidstr"
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    async def test_delete_cascades_sms(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        sms = Sms(
+            project_id=default_project_id,
+            content="coucou",
+            phone_number="02 66 66 66 66 66",
+            volunteer_id=default_volunteer_id,
+        )
+        session.add(sms)
+        session.commit()
+        sms_id = sms.id
+
+        response = await client.delete(
+            app.url_path_for(
+                "delete_volunteer", project_id=default_project_id, volunteer_id=default_volunteer_id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        assert session.get(Volunteer, default_volunteer_id) is None
+        assert session.execute(select(Sms).where(Sms.id == sms_id)).scalar_one_or_none() is None

+ 0 - 36
docker-compose.dev.yml

@@ -1,36 +0,0 @@
-version: "3.7"
-
-# Database + Webserver (under http, for testing setup on localhost:80)
-#
-# docker-compose -f docker-compose.dev.yml up -d
-#
-
-services:
-  postgres:
-    restart: unless-stopped
-    image: postgres:latest
-    volumes:
-      - postgres_data:/var/lib/postgresql/data
-    env_file:
-      - .env
-    environment:
-      - POSTGRES_DB=${DEFAULT_DATABASE_DB}
-      - POSTGRES_USER=${DEFAULT_DATABASE_USER}
-      - POSTGRES_PASSWORD=${DEFAULT_DATABASE_PASSWORD}
-  web:
-    depends_on:
-      - postgres
-    restart: "unless-stopped"
-    build:
-      context: ./
-      dockerfile: Dockerfile
-    env_file:
-      - .env
-    environment:
-      - DEFAULT_DATABASE_HOSTNAME=postgres
-      - DEFAULT_DATABASE_PORT=5432
-    ports:
-      - 80:8000
-
-volumes:
-  postgres_data:

+ 0 - 40
docker-compose.yml

@@ -1,40 +0,0 @@
-version: "3.7"
-
-# For local development, only database is running
-#
-# docker-compose up -d
-# uvicorn app.main:app --reload
-#
-
-services:
-  default_database:
-    restart: unless-stopped
-    image: postgres:latest
-    volumes:
-      - default_database_data:/var/lib/postgresql/data
-    environment:
-      - POSTGRES_DB=${DEFAULT_DATABASE_DB}
-      - POSTGRES_USER=${DEFAULT_DATABASE_USER}
-      - POSTGRES_PASSWORD=${DEFAULT_DATABASE_PASSWORD}
-    env_file:
-      - .env
-    ports:
-      - "${DEFAULT_DATABASE_PORT}:5432"
-
-  test_database:
-    restart: unless-stopped
-    image: postgres:latest
-    volumes:
-      - test_database_data:/var/lib/postgresql/data
-    environment:
-      - POSTGRES_DB=${TEST_DATABASE_DB}
-      - POSTGRES_USER=${TEST_DATABASE_USER}
-      - POSTGRES_PASSWORD=${TEST_DATABASE_PASSWORD}
-    env_file:
-      - .env
-    ports:
-      - "${TEST_DATABASE_PORT}:5432"
-
-volumes:
-  test_database_data:
-  default_database_data:

+ 222 - 92
poetry.lock

@@ -1,4 +1,20 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
+
+[[package]]
+name = "aiosmtplib"
+version = "5.1.2"
+description = "asyncio SMTP client"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+    {file = "aiosmtplib-5.1.2-py3-none-any.whl", hash = "sha256:070d467cc329dafd0af59108ba5d217d973cba10309910fed359a2a7bfb52d7a"},
+    {file = "aiosmtplib-5.1.2.tar.gz", hash = "sha256:04a0ea3c678f5b719f998f290dce010ca512e1385836d3944206299df03b060f"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
+uvloop = ["uvloop (>=0.18)"]
 
 [[package]]
 name = "alembic"
@@ -6,6 +22,7 @@ version = "1.16.5"
 description = "A database migration tool for SQLAlchemy."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"},
     {file = "alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e"},
@@ -25,6 +42,7 @@ version = "0.7.0"
 description = "Reusable constraint types to use with typing.Annotated"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
     {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -36,6 +54,7 @@ version = "4.10.0"
 description = "High-level concurrency and networking framework on top of asyncio or Trio"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
     {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
@@ -50,75 +69,82 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
 trio = ["trio (>=0.26.1)"]
 
 [[package]]
-name = "bcrypt"
-version = "4.3.0"
-description = "Modern password hashing for your software and your servers"
+name = "async-timeout"
+version = "5.0.1"
+description = "Timeout context manager for asyncio programs"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
+markers = "python_full_version < \"3.11.3\""
 files = [
-    {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"},
-    {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"},
-    {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"},
-    {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"},
-    {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"},
-    {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"},
-    {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"},
-    {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"},
+    {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
+    {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
+]
+
+[[package]]
+name = "bcrypt"
+version = "4.0.1"
+description = "Modern password hashing for your software and your servers"
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+    {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"},
+    {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"},
+    {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"},
+    {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"},
+    {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"},
+    {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"},
 ]
 
 [package.extras]
 tests = ["pytest (>=3.2.1,!=3.3.0)"]
 typecheck = ["mypy"]
 
+[[package]]
+name = "broadcaster"
+version = "0.3.1"
+description = "Simple broadcast channels."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+    {file = "broadcaster-0.3.1-py3-none-any.whl", hash = "sha256:433023ab6b6b4a8da9cbba95910eff52b1e767141419659be287cfd49f2a3ecb"},
+    {file = "broadcaster-0.3.1.tar.gz", hash = "sha256:35d1e174a98346d184cc6558979548340e877ae841c65c7e354153a84fe675d6"},
+]
+
+[package.dependencies]
+anyio = ">=3.4.0,<5"
+redis = {version = "*", optional = true, markers = "extra == \"redis\""}
+
+[package.extras]
+kafka = ["aiokafka"]
+postgres = ["asyncpg"]
+redis = ["redis"]
+test = ["pytest", "pytest-asyncio"]
+
 [[package]]
 name = "certifi"
 version = "2025.8.3"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
     {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
     {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@@ -130,6 +156,8 @@ version = "2.0.0"
 description = "Foreign Function Interface for Python calling C code."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\""
 files = [
     {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
     {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -226,6 +254,7 @@ version = "3.4.0"
 description = "Validate configuration and produce human readable error messages."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
@@ -237,6 +266,7 @@ version = "3.4.3"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
     {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
@@ -325,6 +355,7 @@ version = "8.2.1"
 description = "Composable command line interface toolkit"
 optional = false
 python-versions = ">=3.10"
+groups = ["dev"]
 files = [
     {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
     {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
@@ -339,6 +370,8 @@ version = "0.4.6"
 description = "Cross-platform colored terminal text."
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
 files = [
     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -350,6 +383,7 @@ version = "7.10.6"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
     {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
@@ -442,7 +476,7 @@ files = [
 ]
 
 [package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
 
 [[package]]
 name = "cryptography"
@@ -450,6 +484,7 @@ version = "46.0.1"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 optional = false
 python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+groups = ["main"]
 files = [
     {file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"},
     {file = "cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080"},
@@ -508,7 +543,7 @@ files = [
 ]
 
 [package.dependencies]
-cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""}
+cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
 
 [package.extras]
 docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
@@ -526,6 +561,7 @@ version = "0.4.0"
 description = "Distribution utilities"
 optional = false
 python-versions = "*"
+groups = ["dev"]
 files = [
     {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
     {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
@@ -537,6 +573,7 @@ version = "2.8.0"
 description = "DNS toolkit"
 optional = false
 python-versions = ">=3.10"
+groups = ["main"]
 files = [
     {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
     {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
@@ -549,7 +586,7 @@ doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"]
 doq = ["aioquic (>=1.2.0)"]
 idna = ["idna (>=3.10)"]
 trio = ["trio (>=0.30)"]
-wmi = ["wmi (>=1.5.1)"]
+wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
 
 [[package]]
 name = "email-validator"
@@ -557,6 +594,7 @@ version = "2.3.0"
 description = "A robust email address syntax and deliverability validation library."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
     {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
@@ -572,6 +610,7 @@ version = "0.116.2"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
     {file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
@@ -593,6 +632,7 @@ version = "3.19.1"
 description = "A platform independent file lock."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
     {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
@@ -604,6 +644,8 @@ version = "3.2.4"
 description = "Lightweight in-process concurrent programming"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
+markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
 files = [
     {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
     {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
@@ -613,6 +655,8 @@ files = [
     {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
     {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
     {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
+    {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
+    {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
     {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
     {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
     {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@@ -622,6 +666,8 @@ files = [
     {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
     {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
     {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
+    {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
+    {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
     {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
     {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
     {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@@ -631,6 +677,8 @@ files = [
     {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
     {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
     {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
+    {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
+    {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
     {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
     {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
     {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@@ -640,6 +688,8 @@ files = [
     {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
     {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
     {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
+    {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
+    {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
     {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
     {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
     {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@@ -647,6 +697,8 @@ files = [
     {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
     {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
     {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
+    {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
+    {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
     {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
     {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
     {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@@ -656,6 +708,8 @@ files = [
     {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
     {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
     {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
+    {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
+    {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
     {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
     {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
     {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@@ -671,6 +725,7 @@ version = "0.16.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
     {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -682,6 +737,7 @@ version = "1.0.9"
 description = "A minimal low-level HTTP client."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
     {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -703,6 +759,7 @@ version = "0.6.4"
 description = "A collection of framework independent HTTP protocol utils."
 optional = false
 python-versions = ">=3.8.0"
+groups = ["dev"]
 files = [
     {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"},
     {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"},
@@ -758,6 +815,7 @@ version = "0.28.1"
 description = "The next generation HTTP client."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
     {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -770,7 +828,7 @@ httpcore = "==1.*"
 idna = "*"
 
 [package.extras]
-brotli = ["brotli", "brotlicffi"]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
 cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
@@ -782,6 +840,7 @@ version = "2.6.14"
 description = "File identification library for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"},
     {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"},
@@ -796,6 +855,7 @@ version = "3.10"
 description = "Internationalized Domain Names in Applications (IDNA)"
 optional = false
 python-versions = ">=3.6"
+groups = ["main", "dev"]
 files = [
     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -810,6 +870,7 @@ version = "2.1.0"
 description = "brain-dead simple config-ini parsing"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
     {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
@@ -821,6 +882,7 @@ version = "1.3.10"
 description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
     {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
@@ -840,6 +902,7 @@ version = "3.0.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
@@ -910,6 +973,7 @@ version = "1.10"
 description = "Multiple argument dispatching."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "multimethod-1.10-py3-none-any.whl", hash = "sha256:afd84da9c3d0445c84f827e4d63ad42d17c6d29b122427c6dee9032ac2d2a0d4"},
     {file = "multimethod-1.10.tar.gz", hash = "sha256:daa45af3fe257f73abb69673fd54ddeaf31df0eb7363ad6e1251b7c9b192d8c5"},
@@ -921,6 +985,7 @@ version = "1.1.0"
 description = "Type system extensions for programs checked with the mypy type checker."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
     {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
@@ -932,6 +997,7 @@ version = "1.9.1"
 description = "Node.js virtual environment builder"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
 files = [
     {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
     {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
@@ -943,6 +1009,7 @@ version = "1.26.4"
 description = "Fundamental package for array computing in Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
     {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
@@ -988,6 +1055,7 @@ version = "25.0"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.8"
+groups = ["main", "dev"]
 files = [
     {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
     {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
@@ -999,6 +1067,7 @@ version = "2.1.0"
 description = "Powerful data structures for data analysis, time series, and statistics"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pandas-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40dd20439ff94f1b2ed55b393ecee9cb6f3b08104c2c40b0cb7186a2f0046242"},
     {file = "pandas-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f38e4fedeba580285eaac7ede4f686c6701a9e618d8a857b138a126d067f2f"},
@@ -1028,16 +1097,16 @@ pytz = ">=2020.1"
 tzdata = ">=2022.1"
 
 [package.extras]
-all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.05.0)", "gcsfs (>=2022.05.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.05.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.03.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"]
-aws = ["s3fs (>=2022.05.0)"]
+all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.5.0)", "gcsfs (>=2022.5.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.5.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.3.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"]
+aws = ["s3fs (>=2022.5.0)"]
 clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"]
 compression = ["zstandard (>=0.17.0)"]
-computation = ["scipy (>=1.8.1)", "xarray (>=2022.03.0)"]
+computation = ["scipy (>=1.8.1)", "xarray (>=2022.3.0)"]
 consortium-standard = ["dataframe-api-compat (>=0.1.7)"]
 excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pyxlsb (>=1.0.9)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)"]
 feather = ["pyarrow (>=7.0.0)"]
-fss = ["fsspec (>=2022.05.0)"]
-gcp = ["gcsfs (>=2022.05.0)", "pandas-gbq (>=0.17.5)"]
+fss = ["fsspec (>=2022.5.0)"]
+gcp = ["gcsfs (>=2022.5.0)", "pandas-gbq (>=0.17.5)"]
 hdf5 = ["tables (>=3.7.0)"]
 html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"]
 mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"]
@@ -1057,6 +1126,7 @@ version = "0.20.3"
 description = "A light-weight and flexible data validation and testing tool for statistical data objects."
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "pandera-0.20.3-py3-none-any.whl", hash = "sha256:0c251a3947d8c6d7a0e26602e78fa425268bc9f448f3a0fded85249b158a65c2"},
     {file = "pandera-0.20.3.tar.gz", hash = "sha256:d8542a68493d2dfe3226c186cc145be8d27535d7edf396178e93811515fe3470"},
@@ -1093,6 +1163,7 @@ version = "1.7.4"
 description = "comprehensive password hashing framework supporting over 30 schemes"
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
     {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
@@ -1113,6 +1184,7 @@ version = "4.4.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
     {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
@@ -1129,6 +1201,7 @@ version = "1.6.0"
 description = "plugin and hook calling mechanisms for python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
     {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -1144,6 +1217,7 @@ version = "3.8.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
     {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
@@ -1162,6 +1236,7 @@ version = "2.9.10"
 description = "psycopg2 - Python-PostgreSQL Database Adapter"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"},
     {file = "psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a"},
@@ -1181,6 +1256,8 @@ version = "2.23"
 description = "C parser in Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
 files = [
     {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
     {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
@@ -1192,6 +1269,7 @@ version = "2.11.9"
 description = "Data validation using Python type hints"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"},
     {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"},
@@ -1206,7 +1284,7 @@ typing-inspection = ">=0.4.0"
 
 [package.extras]
 email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata"]
+timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
 
 [[package]]
 name = "pydantic-core"
@@ -1214,6 +1292,7 @@ version = "2.33.2"
 description = "Core functionality for Pydantic validation and serialization"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
     {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
@@ -1325,6 +1404,7 @@ version = "2.10.1"
 description = "Settings management using Pydantic"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"},
     {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"},
@@ -1348,6 +1428,7 @@ version = "2.10.1"
 description = "JSON Web Token implementation in Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
     {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
@@ -1368,6 +1449,7 @@ version = "7.4.4"
 description = "pytest: simple powerful testing with Python"
 optional = false
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
     {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
     {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
@@ -1388,6 +1470,7 @@ version = "0.20.3"
 description = "Pytest support for asyncio"
 optional = false
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
     {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
     {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"},
@@ -1406,6 +1489,7 @@ version = "2.9.0.post0"
 description = "Extensions to the standard Python datetime module"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
     {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
     {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@@ -1420,6 +1504,7 @@ version = "1.1.1"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
     {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"},
@@ -1434,6 +1519,7 @@ version = "0.0.9"
 description = "A streaming multipart parser for Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
     {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
@@ -1448,6 +1534,7 @@ version = "2025.2"
 description = "World timezone definitions, modern and historical"
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
     {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
@@ -1459,6 +1546,7 @@ version = "6.0.2"
 description = "YAML parser and emitter for Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
     {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
@@ -1515,12 +1603,36 @@ files = [
     {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
 ]
 
+[[package]]
+name = "redis"
+version = "8.0.1"
+description = "Python client for Redis database and key-value store"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+    {file = "redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743"},
+    {file = "redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0"},
+]
+
+[package.dependencies]
+async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
+
+[package.extras]
+circuit-breaker = ["pybreaker (>=1.4.0)"]
+hiredis = ["hiredis (>=3.2.0)"]
+jwt = ["pyjwt (>=2.13.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"]
+otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"]
+xxhash = ["xxhash (>=3.6.0,<3.7.0)"]
+
 [[package]]
 name = "requests"
 version = "2.32.5"
 description = "Python HTTP for Humans."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
     {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -1538,30 +1650,30 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "ruff"
-version = "0.13.0"
+version = "0.16.0"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
-files = [
-    {file = "ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004"},
-    {file = "ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9"},
-    {file = "ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823"},
-    {file = "ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768"},
-    {file = "ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb"},
-    {file = "ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e"},
-    {file = "ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60"},
+groups = ["main", "dev"]
+files = [
+    {file = "ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e"},
+    {file = "ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522"},
+    {file = "ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed"},
+    {file = "ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb"},
+    {file = "ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472"},
+    {file = "ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d"},
+    {file = "ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982"},
 ]
 
 [[package]]
@@ -1570,6 +1682,7 @@ version = "1.17.0"
 description = "Python 2 and 3 compatibility utilities"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
     {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
     {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
@@ -1581,6 +1694,7 @@ version = "1.3.1"
 description = "Sniff out which async library your code is running under"
 optional = false
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
     {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
     {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1592,6 +1706,7 @@ version = "2.0.43"
 description = "Database Abstraction Library"
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"},
     {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"},
@@ -1687,6 +1802,7 @@ version = "0.48.0"
 description = "The little ASGI library that shines."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
     {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
@@ -1705,6 +1821,7 @@ version = "4.4.4"
 description = "Run-time type checker for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"},
     {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"},
@@ -1719,10 +1836,12 @@ version = "4.15.0"
 description = "Backported and Experimental Type Hints for Python 3.9+"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
     {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
 ]
+markers = {dev = "python_version < \"3.13\""}
 
 [[package]]
 name = "typing-inspect"
@@ -1730,6 +1849,7 @@ version = "0.9.0"
 description = "Runtime inspection utilities for typing module."
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
     {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
@@ -1745,6 +1865,7 @@ version = "0.4.1"
 description = "Runtime typing introspection tools"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
     {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
@@ -1759,6 +1880,7 @@ version = "2025.2"
 description = "Provider of IANA time zone data"
 optional = false
 python-versions = ">=2"
+groups = ["main"]
 files = [
     {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
     {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
@@ -1770,13 +1892,14 @@ version = "2.5.0"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
     {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -1787,6 +1910,7 @@ version = "0.35.0"
 description = "The lightning-fast ASGI server."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
     {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
@@ -1799,12 +1923,12 @@ h11 = ">=0.8"
 httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
-uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
 [package.extras]
-standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
 
 [[package]]
 name = "uvloop"
@@ -1812,6 +1936,8 @@ version = "0.21.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
 optional = false
 python-versions = ">=3.8.0"
+groups = ["dev"]
+markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
 files = [
     {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"},
     {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"},
@@ -1863,6 +1989,7 @@ version = "20.34.0"
 description = "Virtual Python Environment builder"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"},
     {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"},
@@ -1875,7 +2002,7 @@ platformdirs = ">=3.9.1,<5"
 
 [package.extras]
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
 
 [[package]]
 name = "watchfiles"
@@ -1883,6 +2010,7 @@ version = "1.1.0"
 description = "Simple, modern and high performance file watching and code reload in python."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
     {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
@@ -2001,6 +2129,7 @@ version = "15.0.1"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
     {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
@@ -2079,6 +2208,7 @@ version = "1.17.3"
 description = "Module for decorators, wrappers and monkey patching."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -2164,6 +2294,6 @@ files = [
 ]
 
 [metadata]
-lock-version = "2.0"
+lock-version = "2.1"
 python-versions = "^3.11"
-content-hash = "15c67dd6e13d87218f9ea6a5cfe14f30a83b1e304d937941eb84e6877f41558d"
+content-hash = "cd4255985724c76c2c60d622c0f550423b604d4b8973bf34737e8990989a9494"

+ 12 - 5
pyproject.toml

@@ -2,33 +2,40 @@
 authors = ["clovis jaquin <clovis@jaquin.fr>"]
 description = "FastAPI project that can parse gsheet planning for brass dans la garonne event and manage creating automatic SMS notification for volunteer"
 name = "bdlg-2023"
-version = "0.2.3"
+version = "1.0.1"
 
 [tool.poetry.dependencies]
 fastapi = "0.116.*"
-pyjwt = {extras = ["crypto"], version = "^2.6.0"}
+pyjwt = { extras = ["crypto"], version = "^2.6.0" }
 python = "^3.11"
 python-multipart = "^0.0.9"
 sqlalchemy = "2.0.*"
 alembic = "^1.9.2"
 numpy = '<2.0.0'
-passlib = {extras = ["bcrypt"], version = "^1.7.4"}
-pydantic = {extras = ["dotenv", "email"], version = "2.11.*"}
+passlib = { extras = ["bcrypt"], version = "^1.7.4" }
+pydantic = { extras = ["dotenv", "email"], version = "2.11.*" }
 pandas = "2.1.0"
 pandera = "0.20.3"
 requests = "2.32.*"
 pydantic-settings = "^2.2.1"
 psycopg2 = "^2.9.9"
+ruff = "^0.16.0"
+bcrypt = "4.0.1"
+aiosmtplib = "^5.1.2"
+broadcaster = {extras = ["redis"], version = "^0.3.1"}
 
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"
 httpx = "*"
 pytest = "^7.2.1"
 pytest-asyncio = "^0.20.3"
-uvicorn = {extras = ["standard"], version = "*"}
+uvicorn = { extras = ["standard"], version = "*" }
 pre-commit = "^3.0.4"
 ruff = "*"
 
+[tool.poetry.requires-plugins]
+poetry-plugin-export = ">=1.8"
+
 [build-system]
 build-backend = "poetry.core.masonry.api"
 requires = ["poetry-core>=1.0.0"]

+ 13 - 10
requirements-dev.txt

@@ -1,9 +1,12 @@
+aiosmtplib==5.1.2 ; python_version >= "3.11" and python_version < "4.0"
 alembic==1.16.5 ; python_version >= "3.11" and python_version < "4.0"
 annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
 anyio==4.10.0 ; python_version >= "3.11" and python_version < "4.0"
-bcrypt==4.3.0 ; python_version >= "3.11" and python_version < "4.0"
+async-timeout==5.0.1 ; python_version >= "3.11" and python_full_version < "3.11.3"
+bcrypt==4.0.1 ; python_version >= "3.11" and python_version < "4.0"
+broadcaster==0.3.1 ; python_version >= "3.11" and python_version < "4.0"
 certifi==2025.8.3 ; python_version >= "3.11" and python_version < "4.0"
-cffi==2.0.0 ; python_version >= "3.11" and platform_python_implementation != "PyPy" and python_version < "4.0"
+cffi==2.0.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy"
 cfgv==3.4.0 ; python_version >= "3.11" and python_version < "4.0"
 charset-normalizer==3.4.3 ; python_version >= "3.11" and python_version < "4.0"
 click==8.2.1 ; python_version >= "3.11" and python_version < "4.0"
@@ -15,7 +18,7 @@ dnspython==2.8.0 ; python_version >= "3.11" and python_version < "4.0"
 email-validator==2.3.0 ; python_version >= "3.11" and python_version < "4.0"
 fastapi==0.116.2 ; python_version >= "3.11" and python_version < "4.0"
 filelock==3.19.1 ; python_version >= "3.11" and python_version < "4.0"
-greenlet==3.2.4 ; python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version >= "3.11"
+greenlet==3.2.4 ; python_version >= "3.11" and python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32")
 h11==0.16.0 ; python_version >= "3.11" and python_version < "4.0"
 httpcore==1.0.9 ; python_version >= "3.11" and python_version < "4.0"
 httptools==0.6.4 ; python_version >= "3.11" and python_version < "4.0"
@@ -32,17 +35,16 @@ numpy==1.26.4 ; python_version >= "3.11" and python_version < "4.0"
 packaging==25.0 ; python_version >= "3.11" and python_version < "4.0"
 pandas==2.1.0 ; python_version >= "3.11" and python_version < "4.0"
 pandera==0.20.3 ; python_version >= "3.11" and python_version < "4.0"
-passlib[bcrypt]==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
+passlib==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
 platformdirs==4.4.0 ; python_version >= "3.11" and python_version < "4.0"
 pluggy==1.6.0 ; python_version >= "3.11" and python_version < "4.0"
 pre-commit==3.8.0 ; python_version >= "3.11" and python_version < "4.0"
 psycopg2==2.9.10 ; python_version >= "3.11" and python_version < "4.0"
-pycparser==2.23 ; python_version >= "3.11" and platform_python_implementation != "PyPy" and python_version < "4.0" and implementation_name != "PyPy"
+pycparser==2.23 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" and implementation_name != "PyPy"
 pydantic-core==2.33.2 ; python_version >= "3.11" and python_version < "4.0"
 pydantic-settings==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
 pydantic==2.11.9 ; python_version >= "3.11" and python_version < "4.0"
-pydantic[dotenv,email]==2.11.9 ; python_version >= "3.11" and python_version < "4.0"
-pyjwt[crypto]==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
+pyjwt==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
 pytest-asyncio==0.20.3 ; python_version >= "3.11" and python_version < "4.0"
 pytest==7.4.4 ; python_version >= "3.11" and python_version < "4.0"
 python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0"
@@ -50,8 +52,9 @@ python-dotenv==1.1.1 ; python_version >= "3.11" and python_version < "4.0"
 python-multipart==0.0.9 ; python_version >= "3.11" and python_version < "4.0"
 pytz==2025.2 ; python_version >= "3.11" and python_version < "4.0"
 pyyaml==6.0.2 ; python_version >= "3.11" and python_version < "4.0"
+redis==8.0.1 ; python_version >= "3.11" and python_version < "4.0"
 requests==2.32.5 ; python_version >= "3.11" and python_version < "4.0"
-ruff==0.13.0 ; python_version >= "3.11" and python_version < "4.0"
+ruff==0.16.0 ; python_version >= "3.11" and python_version < "4.0"
 six==1.17.0 ; python_version >= "3.11" and python_version < "4.0"
 sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
 sqlalchemy==2.0.43 ; python_version >= "3.11" and python_version < "4.0"
@@ -62,8 +65,8 @@ typing-inspect==0.9.0 ; python_version >= "3.11" and python_version < "4.0"
 typing-inspection==0.4.1 ; python_version >= "3.11" and python_version < "4.0"
 tzdata==2025.2 ; python_version >= "3.11" and python_version < "4.0"
 urllib3==2.5.0 ; python_version >= "3.11" and python_version < "4.0"
-uvicorn[standard]==0.35.0 ; python_version >= "3.11" and python_version < "4.0"
-uvloop==0.21.0 ; (sys_platform != "win32" and sys_platform != "cygwin") and platform_python_implementation != "PyPy" and python_version >= "3.11" and python_version < "4.0"
+uvicorn==0.35.0 ; python_version >= "3.11" and python_version < "4.0"
+uvloop==0.21.0 ; python_version >= "3.11" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy"
 virtualenv==20.34.0 ; python_version >= "3.11" and python_version < "4.0"
 watchfiles==1.1.0 ; python_version >= "3.11" and python_version < "4.0"
 websockets==15.0.1 ; python_version >= "3.11" and python_version < "4.0"

+ 11 - 7
requirements.txt

@@ -1,15 +1,18 @@
+aiosmtplib==5.1.2 ; python_version >= "3.11" and python_version < "4.0"
 alembic==1.16.5 ; python_version >= "3.11" and python_version < "4.0"
 annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
 anyio==4.10.0 ; python_version >= "3.11" and python_version < "4.0"
-bcrypt==4.3.0 ; python_version >= "3.11" and python_version < "4.0"
+async-timeout==5.0.1 ; python_version >= "3.11" and python_full_version < "3.11.3"
+bcrypt==4.0.1 ; python_version >= "3.11" and python_version < "4.0"
+broadcaster==0.3.1 ; python_version >= "3.11" and python_version < "4.0"
 certifi==2025.8.3 ; python_version >= "3.11" and python_version < "4.0"
-cffi==2.0.0 ; python_version >= "3.11" and platform_python_implementation != "PyPy" and python_version < "4.0"
+cffi==2.0.0 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy"
 charset-normalizer==3.4.3 ; python_version >= "3.11" and python_version < "4.0"
 cryptography==46.0.1 ; python_version >= "3.11" and python_version < "4.0"
 dnspython==2.8.0 ; python_version >= "3.11" and python_version < "4.0"
 email-validator==2.3.0 ; python_version >= "3.11" and python_version < "4.0"
 fastapi==0.116.2 ; python_version >= "3.11" and python_version < "4.0"
-greenlet==3.2.4 ; python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32") and python_version >= "3.11"
+greenlet==3.2.4 ; python_version >= "3.11" and python_version < "3.14" and (platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "win32" or platform_machine == "WIN32")
 idna==3.10 ; python_version >= "3.11" and python_version < "4.0"
 mako==1.3.10 ; python_version >= "3.11" and python_version < "4.0"
 markupsafe==3.0.2 ; python_version >= "3.11" and python_version < "4.0"
@@ -19,19 +22,20 @@ numpy==1.26.4 ; python_version >= "3.11" and python_version < "4.0"
 packaging==25.0 ; python_version >= "3.11" and python_version < "4.0"
 pandas==2.1.0 ; python_version >= "3.11" and python_version < "4.0"
 pandera==0.20.3 ; python_version >= "3.11" and python_version < "4.0"
-passlib[bcrypt]==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
+passlib==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
 psycopg2==2.9.10 ; python_version >= "3.11" and python_version < "4.0"
-pycparser==2.23 ; python_version >= "3.11" and platform_python_implementation != "PyPy" and python_version < "4.0" and implementation_name != "PyPy"
+pycparser==2.23 ; python_version >= "3.11" and python_version < "4.0" and platform_python_implementation != "PyPy" and implementation_name != "PyPy"
 pydantic-core==2.33.2 ; python_version >= "3.11" and python_version < "4.0"
 pydantic-settings==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
 pydantic==2.11.9 ; python_version >= "3.11" and python_version < "4.0"
-pydantic[dotenv,email]==2.11.9 ; python_version >= "3.11" and python_version < "4.0"
-pyjwt[crypto]==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
+pyjwt==2.10.1 ; python_version >= "3.11" and python_version < "4.0"
 python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0"
 python-dotenv==1.1.1 ; python_version >= "3.11" and python_version < "4.0"
 python-multipart==0.0.9 ; python_version >= "3.11" and python_version < "4.0"
 pytz==2025.2 ; python_version >= "3.11" and python_version < "4.0"
+redis==8.0.1 ; python_version >= "3.11" and python_version < "4.0"
 requests==2.32.5 ; python_version >= "3.11" and python_version < "4.0"
+ruff==0.16.0 ; python_version >= "3.11" and python_version < "4.0"
 six==1.17.0 ; python_version >= "3.11" and python_version < "4.0"
 sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
 sqlalchemy==2.0.43 ; python_version >= "3.11" and python_version < "4.0"