瀏覽代碼

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

clovis 1 天之前
父節點
當前提交
14645a0dbf
共有 73 個文件被更改,包括 8300 次插入2678 次删除
  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
 SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG
 ENVIRONMENT=DEV
 ENVIRONMENT=DEV
 ACCESS_TOKEN_EXPIRE_MINUTES=11520
 ACCESS_TOKEN_EXPIRE_MINUTES=11520
@@ -5,6 +6,8 @@ REFRESH_TOKEN_EXPIRE_MINUTES=40320
 BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8001"]
 BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8001"]
 ALLOWED_HOSTS=["localhost", "127.0.0.1"]
 ALLOWED_HOSTS=["localhost", "127.0.0.1"]
 
 
+REDIS_URL=redis://localhost:6379
+
 DEFAULT_DATABASE_HOSTNAME=localhost
 DEFAULT_DATABASE_HOSTNAME=localhost
 DEFAULT_DATABASE_USER=rDGJeEDqAz
 DEFAULT_DATABASE_USER=rDGJeEDqAz
 DEFAULT_DATABASE_PASSWORD=XsPQhCoEfOQZueDjsILetLDUvbvSxAMnrVtgVZpmdcSssUgbvs
 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_EMAIL=example@example.com
 FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC
 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
 # 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
 ## Getting started
 
 
@@ -34,11 +55,13 @@ Run specific tests
 ## Update requirements
 ## 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
 ## 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 (
 from app.api.endpoints import (
     auth,
     auth,
+    commissions,
+    organizations,
     project,
     project,
+    project_stream,
     slots,
     slots,
-    users,
-    volunteers,
     sms,
     sms,
+    sms_sender,
     tags,
     tags,
     templates,
     templates,
-    sms_sender,
+    users,
+    volunteer_groups,
+    volunteers,
 )
 )
 
 
 api_router = APIRouter()
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
 api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
 api_router.include_router(users.router, prefix="/users", tags=["users"])
 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.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(slots.router, tags=["slot"])
 api_router.include_router(tags.router, tags=["tag"])
 api_router.include_router(tags.router, tags=["tag"])
 api_router.include_router(volunteers.router, tags=["volunteer"])
 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(sms.router, tags=["sms"])
 api_router.include_router(templates.router, tags=["template"])
 api_router.include_router(templates.router, tags=["template"])
 api_router.include_router(sms_sender.router, tags=["sms_sender"])
 api_router.include_router(sms_sender.router, tags=["sms_sender"])

+ 127 - 14
app/api/deps.py

@@ -1,18 +1,29 @@
 import time
 import time
-from typing import Generator
+from collections.abc import Generator
+from uuid import UUID
 
 
 import jwt
 import jwt
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Path, status
 from fastapi.security import OAuth2PasswordBearer
 from fastapi.security import OAuth2PasswordBearer
-from sqlalchemy import select
+from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
+from app.api.utils import get_project_organization_id
 from app.core import config, security
 from app.core import config, security
 from app.core.session import session
 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")
 reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
 
 
+FULL_ACCESS_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
 
 
 def get_session() -> Generator[Session, None, None]:
 def get_session() -> Generator[Session, None, None]:
     with session() as db:
     with session() as db:
@@ -25,28 +36,130 @@ async def get_current_user(
     try:
     try:
         payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
         payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
     except jwt.DecodeError:
     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)
     token_data = security.JWTTokenPayload(**payload)
 
 
     if token_data.refresh:
     if token_data.refresh:
         raise HTTPException(
         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())
     now = int(time.time())
     if now < token_data.issued_at or now > token_data.expires_at:
     if now < token_data.issued_at or now > token_data.expires_at:
         raise HTTPException(
         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))
     result = session.execute(select(User).where(User.id == token_data.sub))
     user = result.scalars().first()
     user = result.scalars().first()
-
     if not user:
     if not user:
         raise HTTPException(status_code=404, detail="User not found.")
         raise HTTPException(status_code=404, detail="User not found.")
     return user
     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 uuid import UUID
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 
 from app.api import deps
 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 (
 from app.models import (
+    Commission,
+    GlobalRole,
+    OrgRole,
     Project,
     Project,
     Slot,
     Slot,
-    SlotTemplate,
     SlotTag,
     SlotTag,
+    SlotTemplate,
     Sms,
     Sms,
     User,
     User,
     Volunteer,
     Volunteer,
+    VolunteerGroup,
 )
 )
+from app.schemas.objects import PlanningConstraints
 from app.schemas.requests import (
 from app.schemas.requests import (
+    ProjectConstraintUpdateRequest,
+    ProjectCreateRequest,
     ProjectImportGsheetRequest,
     ProjectImportGsheetRequest,
-    ProjectRequest,
     ProjectSMSBatchRequest,
     ProjectSMSBatchRequest,
+    ProjectUpdateRequest,
 )
 )
 from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
 from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
-from app.importData.gsheet import parseGsheet, extract_doc_uid, ParsingError
 
 
 router = APIRouter()
 router = APIRouter()
 
 
@@ -32,8 +40,14 @@ async def list_project(
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
     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()
     return results.scalars().all()
 
 
 
 
@@ -41,18 +55,27 @@ async def list_project(
 async def list_public_project(
 async def list_public_project(
     session: Session = Depends(deps.get_session),
     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))
     results = session.execute(select(Project).where(Project.is_public))
     return results.scalars().all()
     return results.scalars().all()
 
 
 
 
 @router.post("/project", response_model=ProjectResponse)
 @router.post("/project", response_model=ProjectResponse)
 async def create_project(
 async def create_project(
-    new_project: ProjectRequest,
+    new_project: ProjectCreateRequest,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
     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())
     project = Project(**new_project.model_dump())
     session.add(project)
     session.add(project)
     try:
     try:
@@ -61,7 +84,6 @@ async def create_project(
         raise HTTPException(400, "Project name already exist")
         raise HTTPException(400, "Project name already exist")
 
 
     session.refresh(project)
     session.refresh(project)
-
     return project
     return project
 
 
 
 
@@ -70,7 +92,7 @@ async def get_public_project(
     project_id: UUID,
     project_id: UUID,
     session: Session = Depends(deps.get_session),
     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)
     result = session.get(Project, project_id)
     if (result is None) or not result.is_public:
     if (result is None) or not result.is_public:
         raise HTTPException(status_code=404, detail="Project not found")
         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)
 @router.get("/project/{project_id}", response_model=ProjectResponse)
 async def get_project(
 async def get_project(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Get a project"""
     """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:
     if project is None:
         raise HTTPException(status_code=404, detail="Project not found")
         raise HTTPException(status_code=404, detail="Project not found")
     return project
     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(
 async def update_project(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Edit project"""
     """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.name = edit_project.name
     p.is_public = edit_project.is_public
     p.is_public = edit_project.is_public
     session.commit()
     session.commit()
     return p
     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)
 @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
 async def update_project_from_gsheet(
 async def update_project_from_gsheet(
     project_id: UUID,
     project_id: UUID,
     gsheet: ProjectImportGsheetRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Edit project name"""
     """Edit project name"""
@@ -125,13 +186,11 @@ async def update_project_from_gsheet(
         p.volunteers = []
         p.volunteers = []
         p.tags = []
         p.tags = []
         p.templates = []
         p.templates = []
-    # Parse the gsheets
     try:
     try:
         data = parseGsheet(doc_id, gsheet.satursday_date)
         data = parseGsheet(doc_id, gsheet.satursday_date)
     except ParsingError as exc:
     except ParsingError as exc:
         raise HTTPException(status_code=422, detail=str(exc))
         raise HTTPException(status_code=422, detail=str(exc))
 
 
-    # Create the volunteer list
     volunteer_map: dict[str, Volunteer] = {}
     volunteer_map: dict[str, Volunteer] = {}
     for _, row in data.contact.iterrows():
     for _, row in data.contact.iterrows():
         volunteer = Volunteer(
         volunteer = Volunteer(
@@ -145,17 +204,20 @@ async def update_project_from_gsheet(
         volunteer_map[row.key] = volunteer
         volunteer_map[row.key] = volunteer
         session.add(volunteer)
         session.add(volunteer)
 
 
-    # Create creneau templates
     template_map = {}
     template_map = {}
     tags_map = {tag.title: tag for tag in p.tags}
     tags_map = {tag.title: tag for tag in p.tags}
 
 
     for _, row in data.creneauData.iterrows():
     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(
         template = SlotTemplate(
             project_id=project_id,
             project_id=project_id,
             title=row.title,
             title=row.title,
             description=row.description,
             description=row.description,
             place=row.lieu,
             place=row.lieu,
-            responsible_contact=row.responsable,
+            responsible_override=row.responsable,
         )
         )
 
 
         for s_tag in row.tags.split(","):
         for s_tag in row.tags.split(","):
@@ -172,7 +234,6 @@ async def update_project_from_gsheet(
         session.add(template)
         session.add(template)
 
 
     df_planning = data.planning
     df_planning = data.planning
-    # group planning entry per same name and timing
     date_format = "%Y/%m/%d %H:%M"
     date_format = "%Y/%m/%d %H:%M"
     df_planning["key"] = (
     df_planning["key"] = (
         df_planning.nom.str.strip()
         df_planning.nom.str.strip()
@@ -183,7 +244,6 @@ async def update_project_from_gsheet(
     )
     )
     df_slots = df_planning.groupby("key")
     df_slots = df_planning.groupby("key")
 
 
-    # Create slots
     for key in df_slots.groups.keys():
     for key in df_slots.groups.keys():
         group = df_slots.get_group(key)
         group = df_slots.get_group(key)
         volunteers = group.benevole_nom.tolist()
         volunteers = group.benevole_nom.tolist()
@@ -194,12 +254,10 @@ async def update_project_from_gsheet(
             ending_time=group.end.iloc[0],
             ending_time=group.end.iloc[0],
             required_volunteers=len(volunteers),
             required_volunteers=len(volunteers),
         )
         )
-        # Add volunteer to slots
         for benevole_key in volunteers:
         for benevole_key in volunteers:
             if benevole_key in volunteer_map:
             if benevole_key in volunteer_map:
                 slot.volunteers.append(volunteer_map[benevole_key])
                 slot.volunteers.append(volunteer_map[benevole_key])
 
 
-        # add detail information if available
         template_id = group.template_id.iloc[0]
         template_id = group.template_id.iloc[0]
         if template_id in template_map:
         if template_id in template_map:
             slot.template = template_map[template_id]
             slot.template = template_map[template_id]
@@ -214,30 +272,15 @@ async def update_project_from_gsheet(
 async def create_sms_batch(
 async def create_sms_batch(
     project_id: UUID,
     project_id: UUID,
     sms_batch: ProjectSMSBatchRequest,
     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),
     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))
     slots = session.execute(select(Slot).where(Slot.project_id == project_id))
     sms_list = []
     sms_list = []
-    now = datetime.now(timezone.utc)
+    now = datetime.now(UTC)
     for slot in slots.scalars():
     for slot in slots.scalars():
-        # Replace the slot placeholder by their value
         slot_content = (
         slot_content = (
             sms_batch.template.replace("{titre}", slot.title)
             sms_batch.template.replace("{titre}", slot.title)
             .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
             .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
@@ -245,17 +288,15 @@ async def create_sms_batch(
         )
         )
         if slot.template is not None:
         if slot.template is not None:
             slot_content = slot_content.replace("{description}", slot.template.description).replace(
             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)
         sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
-        # Skip SMS that should have been send before now
         if sending_time < now:
         if sending_time < now:
             continue
             continue
         for volunteer in slot.volunteers:
         for volunteer in slot.volunteers:
             if not volunteer.automatic_sms:
             if not volunteer.automatic_sms:
                 continue
                 continue
-            # Create a new SMS customized for each user attache to the slot
             personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
             personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
                 "{nom}", volunteer.surname
                 "{nom}", volunteer.surname
             )
             )
@@ -276,7 +317,7 @@ async def create_sms_batch(
 @router.delete("/project/{project_id}")
 @router.delete("/project/{project_id}")
 async def delete_project(
 async def delete_project(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete project"""
     """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 sqlalchemy.orm import Session
 
 
 from app.api import deps
 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 (
 from app.models import (
-    Project,
+    OrgRole,
     Slot,
     Slot,
     SlotTemplate,
     SlotTemplate,
     User,
     User,
@@ -20,35 +21,50 @@ from app.schemas.requests import (
 )
 )
 from app.schemas.responses import SlotResponse
 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])
 @router.get("/slots", response_model=list[SlotResponse])
 async def list_project_slots(
 async def list_project_slots(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List slots from project"""
     """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))
     results = session.execute(select(Slot).where(Slot.project_id == project_id))
     return results.scalars().all()
     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(
 async def create_slot(
     project_id: UUID,
     project_id: UUID,
     new_slot: SlotCreateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new slot to the project"""
     """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()
     input_dict = new_slot.model_dump()
     # Extract volunteer list from input dict
     # Extract volunteer list from input dict
@@ -72,12 +88,14 @@ async def create_slot(
     return 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(
 async def update_slot(
     project_id: UUID,
     project_id: UUID,
     slot_id: UUID,
     slot_id: UUID,
     new_slot: SlotUpdateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update a slot from the project"""
     """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)):
     if (slot is None) or (slot.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Slot not found : ")
         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)
     input_dict = new_slot.model_dump(exclude_unset=True)
     if "volunteers" in input_dict:
     if "volunteers" in input_dict:
@@ -124,13 +137,22 @@ async def update_slot(
     return slot
     return slot
 
 
 
 
-@router.delete("/slot/{slot_id}")
+@router.delete("/slot/{slot_id}", openapi_extra={"sse_event": "slot_deleted"})
 async def delete_slot(
 async def delete_slot(
     project_id: UUID,
     project_id: UUID,
     slot_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a slot from the project"""
     """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()
     session.commit()

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

@@ -1,13 +1,15 @@
+from typing import Annotated
 from uuid import UUID
 from uuid import UUID
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.params import Query
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.api import deps
 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 (
 from app.models import (
-    Project,
+    OrgRole,
     Sms,
     Sms,
     User,
     User,
 )
 )
@@ -16,19 +18,24 @@ from app.schemas.responses import SMSResponse
 
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "sms"])
 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])
 @router.get("/sms", response_model=list[SMSResponse])
 async def list_project_sms(
 async def list_project_sms(
     project_id: UUID,
     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),
     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"""
     """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()
     return results.scalars().all()
 
 
 
 
@@ -36,13 +43,11 @@ async def list_project_sms(
 async def create_sms(
 async def create_sms(
     project_id: UUID,
     project_id: UUID,
     new_sms: SmsCreateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new to the project"""
     """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())
     sms = Sms(project_id=project_id, **new_sms.model_dump())
     session.add(sms)
     session.add(sms)
     session.commit()
     session.commit()
@@ -54,7 +59,7 @@ async def update_sms(
     project_id: UUID,
     project_id: UUID,
     sms_id: UUID,
     sms_id: UUID,
     new_sms: SmsUpdateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update an sms from the project"""
     """Update an sms from the project"""
@@ -71,7 +76,7 @@ async def update_sms(
 async def delete_sms(
 async def delete_sms(
     project_id: UUID,
     project_id: UUID,
     sms_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a sms from the project"""
     """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 typing import Annotated
 from uuid import UUID
 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 import select
 from sqlalchemy.orm import Session
 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.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 = APIRouter(prefix="/sms-sender", tags=["sms_sender"])
 
 
 
 
 @router.get("/sms/to-send", response_model=list[SMSResponse])
 @router.get("/sms/to-send", response_model=list[SMSResponse])
 async def list_sms_to_send(
 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),
     session: Session = Depends(deps.get_session),
     max_delay: Annotated[
     max_delay: Annotated[
         int | None, Query(description="the maximum delay a sms should be send with")
         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(
         select(Sms).where(
             (Sms.sending_time > min_sending_time)
             (Sms.sending_time > min_sending_time)
             & (Sms.sending_time < now)
             & (Sms.sending_time < now)
-            & (Sms.send_time == None)  # noqa: E711
+            & (Sms.send_time == None)
         )
         )
     )
     )
     return results.scalars().all()
     return results.scalars().all()
@@ -40,7 +40,7 @@ async def list_sms_to_send(
 async def send_sms_now(
 async def send_sms_now(
     request: Request,
     request: Request,
     sms_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update the SMS to be sent now"""
     """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])
 @router.get("/sms/not-send", response_model=list[SMSResponse])
 async def list_not_sent(
 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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List sms that are not sent"""
     """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()
     return results.scalars().all()
 
 
 
 
 @router.get("/sms/future", response_model=list[SMSResponse])
 @router.get("/sms/future", response_model=list[SMSResponse])
 async def list_future_sms(
 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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List sms that should be sent in the future"""
     """List sms that should be sent in the future"""
@@ -79,7 +79,7 @@ async def list_future_sms(
 @router.post("/status")
 @router.post("/status")
 async def update_status(
 async def update_status(
     request: Request,
     request: Request,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_super_admin),
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update the status of the SMS server"""
     """Update the status of the SMS server"""
@@ -89,7 +89,7 @@ async def update_status(
         status = ServerStatus(id=1)
         status = ServerStatus(id=1)
 
 
     status.host = request.client.host
     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")
     status.user_agent = request.headers.get("user-agent", "unknown")
     session.add(status)
     session.add(status)
     session.commit()
     session.commit()
@@ -107,13 +107,12 @@ async def get_status(
 ):
 ):
     """Get the latest status of the SMS server"""
     """Get the latest status of the SMS server"""
     status: ServerStatus | None = session.query(ServerStatus).filter_by(id=1).first()
     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:
     if not status:
         return SMSServerStatus(
         return SMSServerStatus(
             updated_at=now, host="N/A", user_agent="N/A", message=EnumServerStatus.INVALID
             updated_at=now, host="N/A", user_agent="N/A", message=EnumServerStatus.INVALID
         )
         )
     else:
     else:
-
         msg = (
         msg = (
             EnumServerStatus.INACTIVE
             EnumServerStatus.INACTIVE
             if (now - status.updated_at).total_seconds()
             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 sqlalchemy.orm import Session
 
 
 from app.api import deps
 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 (
 from app.models import (
+    OrgRole,
     Project,
     Project,
     SlotTag,
     SlotTag,
     SlotTemplate,
     SlotTemplate,
@@ -18,25 +19,26 @@ from app.schemas.responses import SlotResponse, TagResponse
 
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "tag"])
 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])
 @router.get("/tags", response_model=list[TagResponse])
 async def list_project_tags(
 async def list_project_tags(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List slots from project"""
     """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
     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(
 async def create_tag(
     project_id: UUID,
     project_id: UUID,
     payload: TagCreateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new tag to the project"""
     """Create a new tag to the project"""
@@ -65,12 +67,14 @@ async def create_tag(
     return 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(
 async def update_tag(
     project_id: UUID,
     project_id: UUID,
     tag_id: UUID,
     tag_id: UUID,
     payload: TagUpdateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update a tag"""
     """Update a tag"""
@@ -110,7 +114,7 @@ async def update_tag(
 async def list_tagged_slot(
 async def list_tagged_slot(
     project_id: UUID,
     project_id: UUID,
     tag_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """list all slot associated with a given tag"""
     """list all slot associated with a given tag"""
@@ -124,11 +128,11 @@ async def list_tagged_slot(
     return list(slot_set)
     return list(slot_set)
 
 
 
 
-@router.delete("/tag/{tag_id}")
+@router.delete("/tag/{tag_id}", openapi_extra={"sse_event": "tag_deleted"})
 async def delete_tag(
 async def delete_tag(
     project_id: UUID,
     project_id: UUID,
     tag_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a slot from the project"""
     """Delete a slot from the project"""

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

@@ -1,12 +1,13 @@
 from uuid import UUID
 from uuid import UUID
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import delete
+from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.api import deps
 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 (
 from app.models import (
+    OrgRole,
     Project,
     Project,
     SlotTag,
     SlotTag,
     SlotTemplate,
     SlotTemplate,
@@ -16,13 +17,17 @@ from app.models import (
 from app.schemas.requests import TemplateCreateRequest, TemplateUpdateRequest
 from app.schemas.requests import TemplateCreateRequest, TemplateUpdateRequest
 from app.schemas.responses import TemplateResponse
 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(
 async def list_project_templates(
     project_id: UUID,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List slots from project"""
     """List slots from project"""
@@ -32,19 +37,20 @@ async def list_project_templates(
     return p.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(
 async def create_template(
     project_id: UUID,
     project_id: UUID,
     payload: TemplateCreateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new template to the project"""
     """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.add(template)
     session.commit()
     session.commit()
@@ -66,18 +72,26 @@ async def create_template(
     return 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(
 async def update_template(
     project_id: UUID,
     project_id: UUID,
     template_id: UUID,
     template_id: UUID,
     payload: TemplateUpdateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update a template"""
     """Update a template"""
     template = session.get(SlotTemplate, template_id)
     template = session.get(SlotTemplate, template_id)
     if (template is None) or (template.project_id != str(project_id)):
     if (template is None) or (template.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Template not found")
         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)
     input_dict = payload.model_dump(exclude_unset=True)
     if payload.tags is not None:
     if payload.tags is not None:
@@ -105,14 +119,24 @@ async def update_template(
     return 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(
 async def delete_template(
     project_id: UUID,
     project_id: UUID,
     template_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a slot from the project"""
     """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(
     session.execute(
         delete(SlotTemplate).where(
         delete(SlotTemplate).where(
             (SlotTemplate.id == template_id) & (SlotTemplate.project_id == project_id)
             (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 fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 
 from app.api import deps
 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
 from app.schemas.responses import UserResponse
 
 
 router = APIRouter()
 router = APIRouter()
@@ -16,8 +28,27 @@ async def list_users(
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
     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)
 @router.get("/me", response_model=UserResponse)
@@ -28,6 +59,26 @@ async def read_current_user(
     return 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)
 @router.delete("/me", status_code=204)
 async def delete_current_user(
 async def delete_current_user(
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -56,14 +107,61 @@ async def register_new_user(
     new_user: UserCreateRequest,
     new_user: UserCreateRequest,
     session: Session = Depends(deps.get_session),
     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))
     result = session.execute(select(User).where(User.email == new_user.email))
     if result.scalars().first() is not None:
     if result.scalars().first() is not None:
         raise HTTPException(status_code=400, detail="Cannot use this email address")
         raise HTTPException(status_code=400, detail="Cannot use this email address")
     user = User(
     user = User(
         email=new_user.email,
         email=new_user.email,
+        name=new_user.name,
         hashed_password=get_password_hash(new_user.password),
         hashed_password=get_password_hash(new_user.password),
     )
     )
     session.add(user)
     session.add(user)
     session.commit()
     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
     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 import deps
 from app.api.utils import update_object_from_payload, verify_id_list
 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.requests import VolunteerCreateRequest, VolunteerUpdateRequest
 from app.schemas.responses import VolunteerResponse
 from app.schemas.responses import VolunteerResponse
 
 
 router = APIRouter(prefix="/project/{project_id}", tags=["volunteers"])
 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])
 @router.get("/volunteers", response_model=list[VolunteerResponse])
 async def list_project_volunteers(
 async def list_project_volunteers(
     project_id: UUID,
     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),
     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))
     results = session.execute(select(Volunteer).where(Volunteer.project_id == project_id))
     return results.scalars().all()
     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(
 async def create_volunteer(
     project_id: UUID,
     project_id: UUID,
     new_volunteer: VolunteerCreateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new volunteer to the project"""
     """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()
     input_dict = new_volunteer.model_dump()
 
 
-    # Extract slots list from input dict
     slots: list[UUID] = []
     slots: list[UUID] = []
     if input_dict["slots"] is not None:
     if input_dict["slots"] is not None:
         slots = input_dict["slots"]
         slots = input_dict["slots"]
@@ -50,7 +49,6 @@ async def create_volunteer(
 
 
     volunteer = Volunteer(project_id=project_id, **input_dict)
     volunteer = Volunteer(project_id=project_id, **input_dict)
     session.add(volunteer)
     session.add(volunteer)
-    # commit to optain an id for the volunteer
     session.commit()
     session.commit()
 
 
     if len(slots) > 0:
     if len(slots) > 0:
@@ -60,15 +58,20 @@ async def create_volunteer(
             )
             )
         )
         )
     session.commit()
     session.commit()
+    session.refresh(volunteer)
     return 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(
 async def update_volunteer(
     project_id: UUID,
     project_id: UUID,
     volunteer_id: UUID,
     volunteer_id: UUID,
     new_volunteer: VolunteerUpdateRequest,
     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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update a volunteer from the project"""
     """Update a volunteer from the project"""
@@ -77,17 +80,14 @@ async def update_volunteer(
         raise HTTPException(status_code=404, detail="Volunteer not found")
         raise HTTPException(status_code=404, detail="Volunteer not found")
 
 
     input_dict = new_volunteer.model_dump(exclude_unset=True)
     input_dict = new_volunteer.model_dump(exclude_unset=True)
-    # Extract slots list from input dict
     if "slots" in input_dict:
     if "slots" in input_dict:
         slots: list[UUID] = input_dict["slots"]
         slots: list[UUID] = input_dict["slots"]
         await verify_id_list(session, slots, project_id, Slot, "Invalid slot list")
         await verify_id_list(session, slots, project_id, Slot, "Invalid slot list")
-        # Remove previous values
         session.execute(
         session.execute(
             association_table_volunteer_slot.delete().where(
             association_table_volunteer_slot.delete().where(
                 association_table_volunteer_slot.c.volunteer_id == volunteer.id
                 association_table_volunteer_slot.c.volunteer_id == volunteer.id
             )
             )
         )
         )
-        # Add the new slots
         if len(slots) > 0:
         if len(slots) > 0:
             session.execute(
             session.execute(
                 association_table_volunteer_slot.insert().values(
                 association_table_volunteer_slot.insert().values(
@@ -102,11 +102,14 @@ async def update_volunteer(
     return volunteer
     return volunteer
 
 
 
 
-@router.delete("/volunteer/{volunteer_id}")
+@router.delete(
+    "/volunteer/{volunteer_id}",
+    openapi_extra={"sse_event": "volunteer_deleted"},
+)
 async def delete_volunteer(
 async def delete_volunteer(
     project_id: UUID,
     project_id: UUID,
     volunteer_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),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a volunteer from the project"""
     """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 uuid import UUID
 
 
 from fastapi import HTTPException
 from fastapi import HTTPException
-from sqlalchemy import select
+from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from sqlalchemy.sql import func
 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(
 async def verify_id_list(
     session: Session,
     session: Session,
@@ -38,3 +69,40 @@ def update_object_from_payload(obj, payload: dict):
     """Update the ORM model object from a pydantic payload dictionary"""
     """Update the ORM model object from a pydantic payload dictionary"""
     for attr_name, value in payload.items():
     for attr_name, value in payload.items():
         setattr(obj, attr_name, value)
         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):
 class Settings(BaseSettings):
     # CORE SETTINGS
     # CORE SETTINGS
+    FRONT_END_URL: str
     SECRET_KEY: str
     SECRET_KEY: str
+    RESET_REQUEST_LIMIT: int = 3
+    RESET_REQUEST_WINDOW_HOUR: int = 1
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     SECURITY_BCRYPT_ROUNDS: int = 12
     SECURITY_BCRYPT_ROUNDS: int = 12
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
@@ -45,13 +48,25 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
     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, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     DESCRIPTION: str = PYPROJECT_CONTENT["description"]
     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
     # POSTGRESQL DEFAULT DATABASE
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_USER: str
     DEFAULT_DATABASE_USER: str
@@ -98,5 +113,4 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
 
 
-
 settings: Settings = Settings()  # type: ignore
 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
 import time
+from datetime import UTC, datetime, timedelta
 
 
 import jwt
 import jwt
 from passlib.context import CryptContext
 from passlib.context import CryptContext
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
 
 
 from app.core import config
 from app.core import config
+from app.models import PasswordResetRequestLog
 from app.schemas.responses import AccessTokenResponse
 from app.schemas.responses import AccessTokenResponse
 
 
 JWT_ALGORITHM = "HS256"
 JWT_ALGORITHM = "HS256"
@@ -26,6 +32,15 @@ class JWTTokenPayload(BaseModel):
     expires_at: int
     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):
 def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool):
     """Creates jwt access or refresh token for user.
     """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:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verifies plain and hashed password matches
     """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.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     """
     return PWD_CONTEXT.verify(plain_password, hashed_password)
     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:
 def get_password_hash(password: str) -> str:
     """Creates hash from password
     """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.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     """
     return PWD_CONTEXT.hash(password)
     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 sqlalchemy import select
-from datetime import datetime, timedelta
+
 from app.core import config
 from app.core import config
 from app.core.session import session
 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"
 TEST_SMS_PROJECT_NAME = "test_project pour sms"
 NUMBER_OF_SMS = 80
 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 import config, security
 from app.core.session import session
 from app.core.session import session
-from app.models import User
+from app.models import GlobalRole, User
 
 
 
 
 def main() -> None:
 def main() -> None:
@@ -24,13 +24,14 @@ def main() -> None:
                 hashed_password=security.get_password_hash(
                 hashed_password=security.get_password_hash(
                     config.settings.FIRST_SUPERUSER_PASSWORD
                     config.settings.FIRST_SUPERUSER_PASSWORD
                 ),
                 ),
+                global_role=GlobalRole.SUPER_ADMIN,
             )
             )
             db.add(new_superuser)
             db.add(new_superuser)
-            db.commit()
             print("Superuser was created")
             print("Superuser was created")
         else:
         else:
             print("Superuser already exists in database")
             print("Superuser already exists in database")
-
+            user.global_role = GlobalRole.SUPER_ADMIN
+        db.commit()
         print("Initial data created")
         print("Initial data created")
 
 
 
 

+ 101 - 1
app/main.py

@@ -1,11 +1,24 @@
 """Main FastAPI app instance declaration."""
 """Main FastAPI app instance declaration."""
 
 
+import typing
+from contextlib import asynccontextmanager
+
 from fastapi import FastAPI
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.trustedhost import TrustedHostMiddleware
 from fastapi.middleware.trustedhost import TrustedHostMiddleware
+from fastapi.openapi.utils import get_openapi
 
 
 from app.api.api import api_router
 from app.api.api import api_router
 from app.core import config
 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(
 app = FastAPI(
     title=config.settings.PROJECT_NAME,
     title=config.settings.PROJECT_NAME,
@@ -13,6 +26,7 @@ app = FastAPI(
     description=config.settings.DESCRIPTION,
     description=config.settings.DESCRIPTION,
     openapi_url="/openapi.json",
     openapi_url="/openapi.json",
     docs_url="/",
     docs_url="/",
+    lifespan=lifespan,
 )
 )
 app.include_router(api_router)
 app.include_router(api_router)
 
 
@@ -26,4 +40,90 @@ app.add_middleware(
 )
 )
 
 
 # Guards against HTTP Host Header attacks
 # 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
 alembic upgrade head
 """
 """
 
 
-from typing import Optional
+import enum
 import uuid
 import uuid
 from datetime import datetime
 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.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
 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()))
     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):
 class User(Base):
     __tablename__ = "user_model"
     __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)
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
     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):
 class Project(Base):
     __tablename__ = "projects"
     __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())
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
     )
     name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
     name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
     is_public: Mapped[bool] = mapped_column(Boolean())
     is_public: Mapped[bool] = mapped_column(Boolean())
+    constraints: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
+
     volunteers: Mapped[list["Volunteer"]] = relationship(
     volunteers: Mapped[list["Volunteer"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
         back_populates="project", cascade="delete, delete-orphan"
     )
     )
@@ -66,6 +164,42 @@ class Project(Base):
     tags: Mapped[list["SlotTag"]] = relationship(
     tags: Mapped[list["SlotTag"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
         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(
 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),
     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):
 class Volunteer(Base):
     __tablename__ = "volunteers"
     __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")
     project: Mapped["Project"] = relationship(back_populates="volunteers")
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(
     updated_at: Mapped[datetime] = mapped_column(
@@ -100,6 +262,10 @@ class Volunteer(Base):
     comment: Mapped[str] = mapped_column(String(), default="")
     comment: Mapped[str] = mapped_column(String(), default="")
     sms: Mapped[list["Sms"]] = relationship(back_populates="volunteer", cascade="all, delete")
     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
     @hybrid_property
     def slots_id(self) -> list[str]:
     def slots_id(self) -> list[str]:
         return [s.id for s in self.slots]
         return [s.id for s in self.slots]
@@ -108,8 +274,8 @@ class Volunteer(Base):
 class Slot(Base):
 class Slot(Base):
     __tablename__ = "slots"
     __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")
     project: Mapped["Project"] = relationship(back_populates="slots")
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     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"
         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
         ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
     )
     )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
@@ -152,8 +318,8 @@ association_table_template_tags = Table(
 class SlotTag(Base):
 class SlotTag(Base):
     __tablename__ = "slot_tags"
     __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")
     project: Mapped["Project"] = relationship(back_populates="tags")
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -174,8 +340,8 @@ class SlotTag(Base):
 class SlotTemplate(Base):
 class SlotTemplate(Base):
     __tablename__ = "slot_templates"
     __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")
     project: Mapped["Project"] = relationship(back_populates="templates")
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     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="")
     title: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     place: 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")
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
 
@@ -203,6 +373,18 @@ class SlotTemplate(Base):
     def tags_id(self) -> list[str]:
     def tags_id(self) -> list[str]:
         return [s.id for s in self.tags]
         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):
 class Sms(Base):
     __tablename__ = "sms"
     __tablename__ = "sms"
@@ -210,10 +392,10 @@ class Sms(Base):
     id: Mapped[str] = mapped_column(
     id: Mapped[str] = mapped_column(
         UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
         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")
     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"),
         ForeignKey("volunteers.id", ondelete="CASCADE", onupdate="CASCADE"),
         nullable=True,
         nullable=True,
     )
     )
@@ -232,6 +414,8 @@ class ServerStatus(Base):
     __tablename__ = "server_status"
     __tablename__ = "server_status"
     # Use a fixed ID to ensure we only ever have one row
     # Use a fixed ID to ensure we only ever have one row
     id = Column(Integer, primary_key=True, default=1)
     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)
     host = Column(String, nullable=False)
     user_agent = Column(String)
     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
 import datetime
-from typing import Literal, Optional
+from typing import Literal
 
 
 from pydantic import UUID4, BaseModel, EmailStr, Field
 from pydantic import UUID4, BaseModel, EmailStr, Field
 
 
+from app.models import OrgRole
+from app.schemas.objects import PlanningConstraints
+
 
 
 class BaseRequest(BaseModel):
 class BaseRequest(BaseModel):
     # may define additional fields or config shared across requests
     # may define additional fields or config shared across requests
     pass
     pass
 
 
 
 
+class ForgotPasswordRequest(BaseRequest):
+    email: EmailStr
+
+
+class SetPasswordWithTokenRequest(BaseRequest):
+    token: str
+    password: str
+
+
 class RefreshTokenRequest(BaseRequest):
 class RefreshTokenRequest(BaseRequest):
     refresh_token: str
     refresh_token: str
 
 
 
 
-class UserUpdatePasswordRequest(BaseRequest):
+class UserCreateRequest(BaseRequest):
+    email: EmailStr
     password: str
     password: str
+    name: str
 
 
 
 
-class UserCreateRequest(BaseRequest):
+class UserInviteRequest(BaseRequest):
     email: EmailStr
     email: EmailStr
+    name: str
+    phone_number: str | None = None
+    org_role: OrgRole = OrgRole.RESPO_COMMISSION
+
+
+class UserUpdatePasswordRequest(BaseRequest):
     password: str
     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):
 class SmsCreateRequest(BaseRequest):
-    volunteer_id: Optional[UUID4] = None
+    volunteer_id: UUID4 | None = None
     phone_number: str
     phone_number: str
     content: str
     content: str
-    sending_time: Optional[datetime.datetime] = None
+    sending_time: datetime.datetime | None = None
 
 
 
 
 class SmsUpdateRequest(BaseRequest):
 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
     name: str
     is_public: bool = Field(
     is_public: bool = Field(
         default=False,
         default=False,
@@ -47,7 +99,7 @@ class ProjectRequest(BaseRequest):
 class ProjectImportGsheetRequest(BaseRequest):
 class ProjectImportGsheetRequest(BaseRequest):
     sheet_url: str
     sheet_url: str
     satursday_date: datetime.datetime
     satursday_date: datetime.datetime
-    erase_data: Optional[bool] = Field(default=False)
+    erase_data: bool | None = Field(default=False)
 
 
 
 
 class ProjectSMSBatchRequest(BaseRequest):
 class ProjectSMSBatchRequest(BaseRequest):
@@ -59,7 +111,7 @@ class ProjectSMSBatchRequest(BaseRequest):
      - {description} slot.description
      - {description} slot.description
      - {debut} slot.starting_time
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
      - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
+     - {respo} slot.responsible_override
      - {prenom} volunteer.name
      - {prenom} volunteer.name
      - {nom} volunteer.surname""",
      - {nom} volunteer.surname""",
         examples=["Bonjour {prenom},\nTon créneau {titre} commence à {debut}.\nla com bénévole"],
         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):
 class VolunteerCreateRequest(BaseRequest):
     name: str
     name: str
-    surname: Optional[str] = Field(default="")
+    surname: str | None = Field(default="")
     email: EmailStr
     email: EmailStr
     phone_number: str
     phone_number: str
     automatic_sms: bool = Field(default=True)
     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):
 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):
 class SlotCreateRequest(BaseRequest):
     title: str
     title: str
     starting_time: datetime.datetime
     starting_time: datetime.datetime
     ending_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):
 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):
 class TemplateCreateRequest(BaseRequest):
     title: str
     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):
 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
     # 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):
 class TagCreateRequest(BaseRequest):
     title: str
     title: str
-    templates: Optional[list[UUID4]] = None
+    templates: list[UUID4] | None = None
 
 
 
 
 class TagUpdateRequest(BaseRequest):
 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 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):
 class BaseResponse(BaseModel):
-    # may define additional fields or config shared across responses
     model_config = ConfigDict(from_attributes=True)
     model_config = ConfigDict(from_attributes=True)
 
 
 
 
 class BaseObjectResponse(BaseResponse):
 class BaseObjectResponse(BaseResponse):
-    id: str
+    id: UUID4
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 
@@ -25,9 +26,55 @@ class AccessTokenResponse(BaseResponse):
     refresh_token_issued_at: int
     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):
 class UserResponse(BaseResponse):
     id: str
     id: str
     email: EmailStr
     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):
 class VolunteerResponse(BaseObjectResponse):
@@ -36,7 +83,8 @@ class VolunteerResponse(BaseObjectResponse):
     email: str
     email: str
     phone_number: str
     phone_number: str
     automatic_sms: bool
     automatic_sms: bool
-    slots_id: list[str] = []
+    slots_id: list[UUID4] = []
+    groups_id: list[str] = []
     comment: str
     comment: str
 
 
 
 
@@ -46,23 +94,24 @@ class SlotResponse(BaseObjectResponse):
     ending_time: datetime
     ending_time: datetime
     required_volunteers: int
     required_volunteers: int
     volunteers_id: list[str] = []
     volunteers_id: list[str] = []
-    template_id: Optional[str]
+    template_id: UUID4 | None
 
 
 
 
 class SMSResponse(BaseObjectResponse):
 class SMSResponse(BaseObjectResponse):
-    volunteer_id: Optional[str]
+    volunteer_id: UUID4 | None
     content: str
     content: str
     phone_number: str
     phone_number: str
     sending_time: datetime
     sending_time: datetime
-    send_time: Optional[datetime]
+    send_time: datetime | None
 
 
 
 
 class TemplateResponse(BaseObjectResponse):
 class TemplateResponse(BaseObjectResponse):
     title: str
     title: str
     description: str
     description: str
     place: 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
     comment: str
 
 
 
 
@@ -73,19 +122,24 @@ class TagResponse(BaseObjectResponse):
 
 
 class ProjectResponse(BaseObjectResponse):
 class ProjectResponse(BaseObjectResponse):
     name: str
     name: str
+    organization_id: str
     volunteers: list[VolunteerResponse]
     volunteers: list[VolunteerResponse]
     sms: list[SMSResponse]
     sms: list[SMSResponse]
     slots: list[SlotResponse]
     slots: list[SlotResponse]
     tags: list[TagResponse]
     tags: list[TagResponse]
     templates: list[TemplateResponse]
     templates: list[TemplateResponse]
+    groups: list[VolunteerGroupResponse] = []
+    commissions: list[CommissionResponse] = []
+    constraints: PlanningConstraints
     is_public: bool
     is_public: bool
 
 
 
 
 class ProjectListResponse(BaseResponse):
 class ProjectListResponse(BaseResponse):
-    id: str
+    id: UUID4
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
     name: str
     name: str
+    organization_id: str
     is_public: bool
     is_public: bool
 
 
 
 

+ 158 - 22
app/tests/conftest.py

@@ -1,19 +1,32 @@
 import asyncio
 import asyncio
 from collections.abc import AsyncGenerator
 from collections.abc import AsyncGenerator
 from datetime import datetime
 from datetime import datetime
+from uuid import uuid4
 
 
 import pytest
 import pytest
 import pytest_asyncio
 import pytest_asyncio
-from httpx import AsyncClient
+from httpx import ASGITransport, AsyncClient
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.core import config, security
 from app.core import config, security
 from app.core.session import engine
 from app.core.session import engine
 from app.core.session import session as session_maker
 from app.core.session import session as session_maker
-
 from app.main import app
 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_id = "b75365d9-7bf9-4f54-add5-aeab333a087b"
 default_user_email = "geralt@wiedzmin.pl"
 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_tag_id = "47709be8-7edb-4fb1-9de0-8a529a91856d"
 default_template_id = "dcfcf2e9-8b1d-4bbf-b44b-aa511a7fe274"
 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")
 @pytest.fixture(scope="session")
 def event_loop():
 def event_loop():
@@ -42,10 +58,7 @@ def event_loop():
 
 
 @pytest_asyncio.fixture(scope="session")
 @pytest_asyncio.fixture(scope="session")
 async def test_db_setup_sessionmaker():
 async def test_db_setup_sessionmaker():
-    # assert if we use TEST_DB URL for 100%
     assert config.settings.ENVIRONMENT == "PYTEST"
     assert config.settings.ENVIRONMENT == "PYTEST"
-
-    # always drop and create test db tables between tests session
     Base.metadata.drop_all(engine)
     Base.metadata.drop_all(engine)
     Base.metadata.create_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]:
 async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
     with session_maker() as db:
     with session_maker() as db:
         yield db
         yield db
-
-        # delete all data from all tables after test
         for name, table in Base.metadata.tables.items():
         for name, table in Base.metadata.tables.items():
             db.execute(delete(table))
             db.execute(delete(table))
         db.commit()
         db.commit()
@@ -63,8 +74,7 @@ async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
 
 
 @pytest_asyncio.fixture(scope="session")
 @pytest_asyncio.fixture(scope="session")
 async def client() -> AsyncGenerator[AsyncClient, None]:
 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
         yield client
 
 
 
 
@@ -77,6 +87,7 @@ def default_user(test_db_setup_sessionmaker) -> User:
             new_user = User(
             new_user = User(
                 email=default_user_email,
                 email=default_user_email,
                 hashed_password=default_user_password_hash,
                 hashed_password=default_user_password_hash,
+                name="Geralt",
             )
             )
             new_user.id = default_user_id
             new_user.id = default_user_id
             db.add(new_user)
             db.add(new_user)
@@ -87,13 +98,135 @@ def default_user(test_db_setup_sessionmaker) -> User:
 
 
 
 
 @pytest.fixture
 @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:
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         project = result.scalars().first()
         if project is None:
         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
             new_project.id = default_project_id
             db.add(new_project)
             db.add(new_project)
             db.commit()
             db.commit()
@@ -103,16 +236,20 @@ def default_project(test_db_setup_sessionmaker) -> Project:
 
 
 
 
 @pytest.fixture
 @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."""
     """A public project with 1 volunteer, 1 slot & 1 sms associated to."""
     with session_maker() as db:
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         project = result.scalars().first()
         if project is None:
         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
             new_project.id = default_project_id
             db.add(new_project)
             db.add(new_project)
-            # Create a volunteer
+
             volunteer = Volunteer(
             volunteer = Volunteer(
                 project_id=default_project_id,
                 project_id=default_project_id,
                 name="Arthur",
                 name="Arthur",
@@ -123,7 +260,7 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             )
             volunteer.id = default_volunteer_id
             volunteer.id = default_volunteer_id
             db.add(volunteer)
             db.add(volunteer)
-            # Create a slot
+
             slot = Slot(
             slot = Slot(
                 project_id=default_project_id,
                 project_id=default_project_id,
                 title="être roi",
                 title="être roi",
@@ -133,10 +270,11 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             slot.id = default_slot_id
             slot.id = default_slot_id
             slot.volunteers.append(volunteer)
             slot.volunteers.append(volunteer)
             db.add(slot)
             db.add(slot)
+
             tag = SlotTag(project_id=default_project_id, title="Royal")
             tag = SlotTag(project_id=default_project_id, title="Royal")
             tag.id = default_tag_id
             tag.id = default_tag_id
             db.add(tag)
             db.add(tag)
-            # Create a sms
+
             sms = Sms(
             sms = Sms(
                 project_id=default_project_id,
                 project_id=default_project_id,
                 content="Bonjour sir",
                 content="Bonjour sir",
@@ -144,14 +282,12 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             )
             sms.id = default_sms_id
             sms.id = default_sms_id
             db.add(sms)
             db.add(sms)
+
             tmp = SlotTemplate(project_id=default_project_id, title="basic template")
             tmp = SlotTemplate(project_id=default_project_id, title="basic template")
             tmp.id = default_template_id
             tmp.id = default_template_id
             db.add(tmp)
             db.add(tmp)
+
             db.commit()
             db.commit()
-            db.commit()
-            db.refresh(sms)
-            db.refresh(slot)
-            db.refresh(volunteer)
             db.refresh(new_project)
             db.refresh(new_project)
             return new_project
             return new_project
         return 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
 import uuid
+from datetime import UTC, datetime
+
+import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 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(
         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.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 datetime import datetime, timedelta
-from httpx import AsyncClient
+
 import pytest
 import pytest
+from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 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
 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:
 def get_arthur(session) -> Volunteer:
     result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
     result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
@@ -16,314 +30,523 @@ def get_arthur(session) -> Volunteer:
     return arthur
     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)
 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 httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 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]]
 [[package]]
 name = "alembic"
 name = "alembic"
@@ -6,6 +22,7 @@ version = "1.16.5"
 description = "A database migration tool for SQLAlchemy."
 description = "A database migration tool for SQLAlchemy."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"},
     {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"},
     {file = "alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e"},
     {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"
 description = "Reusable constraint types to use with typing.Annotated"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
     {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
     {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
     {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"
 description = "High-level concurrency and networking framework on top of asyncio or Trio"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
     {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
     {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
     {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)"]
 trio = ["trio (>=0.26.1)"]
 
 
 [[package]]
 [[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
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
+markers = "python_full_version < \"3.11.3\""
 files = [
 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]
 [package.extras]
 tests = ["pytest (>=3.2.1,!=3.3.0)"]
 tests = ["pytest (>=3.2.1,!=3.3.0)"]
 typecheck = ["mypy"]
 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]]
 [[package]]
 name = "certifi"
 name = "certifi"
 version = "2025.8.3"
 version = "2025.8.3"
 description = "Python package for providing Mozilla's CA Bundle."
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
     {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
     {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
     {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."
 description = "Foreign Function Interface for Python calling C code."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\""
 files = [
 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_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
     {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
     {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."
 description = "Validate configuration and produce human readable error messages."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
     {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
     {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."
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
 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-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"},
     {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"
 description = "Composable command line interface toolkit"
 optional = false
 optional = false
 python-versions = ">=3.10"
 python-versions = ">=3.10"
+groups = ["dev"]
 files = [
 files = [
     {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
     {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
     {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
     {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
@@ -339,6 +370,8 @@ version = "0.4.6"
 description = "Cross-platform colored terminal text."
 description = "Cross-platform colored terminal text."
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 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 = [
 files = [
     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -350,6 +383,7 @@ version = "7.10.6"
 description = "Code coverage measurement for Python"
 description = "Code coverage measurement for Python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 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_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
     {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
     {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
@@ -442,7 +476,7 @@ files = [
 ]
 ]
 
 
 [package.extras]
 [package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
 
 
 [[package]]
 [[package]]
 name = "cryptography"
 name = "cryptography"
@@ -450,6 +484,7 @@ version = "46.0.1"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 optional = false
 optional = false
 python-versions = "!=3.9.0,!=3.9.1,>=3.8"
 python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"},
     {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"},
     {file = "cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080"},
@@ -508,7 +543,7 @@ files = [
 ]
 ]
 
 
 [package.dependencies]
 [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]
 [package.extras]
 docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
 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"
 description = "Distribution utilities"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
+groups = ["dev"]
 files = [
 files = [
     {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
     {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
     {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
     {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
@@ -537,6 +573,7 @@ version = "2.8.0"
 description = "DNS toolkit"
 description = "DNS toolkit"
 optional = false
 optional = false
 python-versions = ">=3.10"
 python-versions = ">=3.10"
+groups = ["main"]
 files = [
 files = [
     {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
     {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
     {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
     {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)"]
 doq = ["aioquic (>=1.2.0)"]
 idna = ["idna (>=3.10)"]
 idna = ["idna (>=3.10)"]
 trio = ["trio (>=0.30)"]
 trio = ["trio (>=0.30)"]
-wmi = ["wmi (>=1.5.1)"]
+wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
 
 
 [[package]]
 [[package]]
 name = "email-validator"
 name = "email-validator"
@@ -557,6 +594,7 @@ version = "2.3.0"
 description = "A robust email address syntax and deliverability validation library."
 description = "A robust email address syntax and deliverability validation library."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
     {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
     {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
     {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"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
     {file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
     {file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
     {file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
@@ -593,6 +632,7 @@ version = "3.19.1"
 description = "A platform independent file lock."
 description = "A platform independent file lock."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
     {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
     {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
     {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
@@ -604,6 +644,8 @@ version = "3.2.4"
 description = "Lightweight in-process concurrent programming"
 description = "Lightweight in-process concurrent programming"
 optional = false
 optional = false
 python-versions = ">=3.9"
 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 = [
 files = [
     {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
     {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"},
     {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-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_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_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-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-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"},
     {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-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_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_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-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-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"},
     {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-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_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_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-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-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"},
     {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-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_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_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-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-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"},
     {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_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-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-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-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-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"},
     {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-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_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_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-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
     {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
     {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
     {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
     {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"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
     {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
     {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
     {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."
 description = "A minimal low-level HTTP client."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
     {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
     {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
     {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."
 description = "A collection of framework independent HTTP protocol utils."
 optional = false
 optional = false
 python-versions = ">=3.8.0"
 python-versions = ">=3.8.0"
+groups = ["dev"]
 files = [
 files = [
     {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"},
     {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"},
     {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."
 description = "The next generation HTTP client."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
     {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
     {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
     {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -770,7 +828,7 @@ httpcore = "==1.*"
 idna = "*"
 idna = "*"
 
 
 [package.extras]
 [package.extras]
-brotli = ["brotli", "brotlicffi"]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
 cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
 socks = ["socksio (==1.*)"]
@@ -782,6 +840,7 @@ version = "2.6.14"
 description = "File identification library for Python"
 description = "File identification library for Python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"},
     {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"},
     {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"},
     {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"},
@@ -796,6 +855,7 @@ version = "3.10"
 description = "Internationalized Domain Names in Applications (IDNA)"
 description = "Internationalized Domain Names in Applications (IDNA)"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -810,6 +870,7 @@ version = "2.1.0"
 description = "brain-dead simple config-ini parsing"
 description = "brain-dead simple config-ini parsing"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
     {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
     {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
     {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."
 description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
     {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
     {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
     {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."
 description = "Safely add untrusted strings to HTML/XML markup."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
     {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"},
     {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."
 description = "Multiple argument dispatching."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "multimethod-1.10-py3-none-any.whl", hash = "sha256:afd84da9c3d0445c84f827e4d63ad42d17c6d29b122427c6dee9032ac2d2a0d4"},
     {file = "multimethod-1.10-py3-none-any.whl", hash = "sha256:afd84da9c3d0445c84f827e4d63ad42d17c6d29b122427c6dee9032ac2d2a0d4"},
     {file = "multimethod-1.10.tar.gz", hash = "sha256:daa45af3fe257f73abb69673fd54ddeaf31df0eb7363ad6e1251b7c9b192d8c5"},
     {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."
 description = "Type system extensions for programs checked with the mypy type checker."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
     {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
     {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
     {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"
 description = "Node.js virtual environment builder"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
 files = [
 files = [
     {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
     {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
     {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
     {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"
 description = "Fundamental package for array computing in Python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 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_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
     {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
     {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"
 description = "Core utilities for Python packages"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
     {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
     {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
     {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"
 description = "Powerful data structures for data analysis, time series, and statistics"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 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_10_9_x86_64.whl", hash = "sha256:40dd20439ff94f1b2ed55b393ecee9cb6f3b08104c2c40b0cb7186a2f0046242"},
     {file = "pandas-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f38e4fedeba580285eaac7ede4f686c6701a9e618d8a857b138a126d067f2f"},
     {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"
 tzdata = ">=2022.1"
 
 
 [package.extras]
 [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)"]
 clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"]
 compression = ["zstandard (>=0.17.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)"]
 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)"]
 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)"]
 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)"]
 hdf5 = ["tables (>=3.7.0)"]
 html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"]
 html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"]
 mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"]
 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."
 description = "A light-weight and flexible data validation and testing tool for statistical data objects."
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
 files = [
     {file = "pandera-0.20.3-py3-none-any.whl", hash = "sha256:0c251a3947d8c6d7a0e26602e78fa425268bc9f448f3a0fded85249b158a65c2"},
     {file = "pandera-0.20.3-py3-none-any.whl", hash = "sha256:0c251a3947d8c6d7a0e26602e78fa425268bc9f448f3a0fded85249b158a65c2"},
     {file = "pandera-0.20.3.tar.gz", hash = "sha256:d8542a68493d2dfe3226c186cc145be8d27535d7edf396178e93811515fe3470"},
     {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"
 description = "comprehensive password hashing framework supporting over 30 schemes"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
+groups = ["main"]
 files = [
 files = [
     {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
     {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
     {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
     {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`."
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
     {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
     {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
     {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"
 description = "plugin and hook calling mechanisms for python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
     {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
     {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
     {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."
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
     {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"},
     {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"
 description = "psycopg2 - Python-PostgreSQL Database Adapter"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"},
     {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"},
     {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"
 description = "C parser in Python"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
 files = [
 files = [
     {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
     {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
     {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
     {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
@@ -1192,6 +1269,7 @@ version = "2.11.9"
 description = "Data validation using Python type hints"
 description = "Data validation using Python type hints"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"},
     {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"},
     {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"},
     {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"},
@@ -1206,7 +1284,7 @@ typing-inspection = ">=0.4.0"
 
 
 [package.extras]
 [package.extras]
 email = ["email-validator (>=2.0.0)"]
 email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata"]
+timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
 
 
 [[package]]
 [[package]]
 name = "pydantic-core"
 name = "pydantic-core"
@@ -1214,6 +1292,7 @@ version = "2.33.2"
 description = "Core functionality for Pydantic validation and serialization"
 description = "Core functionality for Pydantic validation and serialization"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 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_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
     {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
     {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"
 description = "Settings management using Pydantic"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"},
     {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"},
     {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"},
     {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"
 description = "JSON Web Token implementation in Python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
     {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
     {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
     {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"
 description = "pytest: simple powerful testing with Python"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
 files = [
     {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
     {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
     {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
     {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
@@ -1388,6 +1470,7 @@ version = "0.20.3"
 description = "Pytest support for asyncio"
 description = "Pytest support for asyncio"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
 files = [
     {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
     {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
     {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"},
     {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"
 description = "Extensions to the standard Python datetime module"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
 files = [
     {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
     {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"},
     {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"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
     {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
     {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"},
     {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"
 description = "A streaming multipart parser for Python"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
     {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
     {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
     {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"
 description = "World timezone definitions, modern and historical"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
+groups = ["main"]
 files = [
 files = [
     {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
     {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
     {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
     {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
@@ -1459,6 +1546,7 @@ version = "6.0.2"
 description = "YAML parser and emitter for Python"
 description = "YAML parser and emitter for Python"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 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_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
     {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
     {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"},
     {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]]
 [[package]]
 name = "requests"
 name = "requests"
 version = "2.32.5"
 version = "2.32.5"
 description = "Python HTTP for Humans."
 description = "Python HTTP for Humans."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
     {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
     {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
     {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -1538,30 +1650,30 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 
 [[package]]
 [[package]]
 name = "ruff"
 name = "ruff"
-version = "0.13.0"
+version = "0.16.0"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 optional = false
 python-versions = ">=3.7"
 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]]
 [[package]]
@@ -1570,6 +1682,7 @@ version = "1.17.0"
 description = "Python 2 and 3 compatibility utilities"
 description = "Python 2 and 3 compatibility utilities"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
 files = [
     {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
     {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
     {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
     {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"
 description = "Sniff out which async library your code is running under"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
     {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
     {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
     {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1592,6 +1706,7 @@ version = "2.0.43"
 description = "Database Abstraction Library"
 description = "Database Abstraction Library"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
 files = [
     {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"},
     {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"},
     {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."
 description = "The little ASGI library that shines."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
     {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
     {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
     {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"
 description = "Run-time type checker for Python"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"},
     {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"},
     {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"},
     {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+"
 description = "Backported and Experimental Type Hints for Python 3.9+"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
 files = [
     {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
     {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
     {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
     {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
 ]
 ]
+markers = {dev = "python_version < \"3.13\""}
 
 
 [[package]]
 [[package]]
 name = "typing-inspect"
 name = "typing-inspect"
@@ -1730,6 +1849,7 @@ version = "0.9.0"
 description = "Runtime inspection utilities for typing module."
 description = "Runtime inspection utilities for typing module."
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
+groups = ["main"]
 files = [
 files = [
     {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
     {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
     {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
     {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
@@ -1745,6 +1865,7 @@ version = "0.4.1"
 description = "Runtime typing introspection tools"
 description = "Runtime typing introspection tools"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
     {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
     {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
     {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"
 description = "Provider of IANA time zone data"
 optional = false
 optional = false
 python-versions = ">=2"
 python-versions = ">=2"
+groups = ["main"]
 files = [
 files = [
     {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
     {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
     {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
     {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."
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
 files = [
     {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
     {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
     {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
     {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
 ]
 ]
 
 
 [package.extras]
 [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)"]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -1787,6 +1910,7 @@ version = "0.35.0"
 description = "The lightning-fast ASGI server."
 description = "The lightning-fast ASGI server."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 files = [
     {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
     {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
     {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
     {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\""}
 httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", 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\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
 
 [package.extras]
 [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]]
 [[package]]
 name = "uvloop"
 name = "uvloop"
@@ -1812,6 +1936,8 @@ version = "0.21.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
 description = "Fast implementation of asyncio event loop on top of libuv"
 optional = false
 optional = false
 python-versions = ">=3.8.0"
 python-versions = ">=3.8.0"
+groups = ["dev"]
+markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
 files = [
 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_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"},
     {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"},
     {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"
 description = "Virtual Python Environment builder"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
 files = [
     {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"},
     {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"},
     {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"},
     {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"},
@@ -1875,7 +2002,7 @@ platformdirs = ">=3.9.1,<5"
 
 
 [package.extras]
 [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)"]
 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]]
 [[package]]
 name = "watchfiles"
 name = "watchfiles"
@@ -1883,6 +2010,7 @@ version = "1.1.0"
 description = "Simple, modern and high performance file watching and code reload in python."
 description = "Simple, modern and high performance file watching and code reload in python."
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 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_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
     {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
     {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)"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 optional = false
 optional = false
 python-versions = ">=3.9"
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
 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_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
     {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
     {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."
 description = "Module for decorators, wrappers and monkey patching."
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 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_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -2164,6 +2294,6 @@ files = [
 ]
 ]
 
 
 [metadata]
 [metadata]
-lock-version = "2.0"
+lock-version = "2.1"
 python-versions = "^3.11"
 python-versions = "^3.11"
-content-hash = "15c67dd6e13d87218f9ea6a5cfe14f30a83b1e304d937941eb84e6877f41558d"
+content-hash = "cd4255985724c76c2c60d622c0f550423b604d4b8973bf34737e8990989a9494"

+ 12 - 5
pyproject.toml

@@ -2,33 +2,40 @@
 authors = ["clovis jaquin <clovis@jaquin.fr>"]
 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"
 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"
 name = "bdlg-2023"
-version = "0.2.3"
+version = "1.0.1"
 
 
 [tool.poetry.dependencies]
 [tool.poetry.dependencies]
 fastapi = "0.116.*"
 fastapi = "0.116.*"
-pyjwt = {extras = ["crypto"], version = "^2.6.0"}
+pyjwt = { extras = ["crypto"], version = "^2.6.0" }
 python = "^3.11"
 python = "^3.11"
 python-multipart = "^0.0.9"
 python-multipart = "^0.0.9"
 sqlalchemy = "2.0.*"
 sqlalchemy = "2.0.*"
 alembic = "^1.9.2"
 alembic = "^1.9.2"
 numpy = '<2.0.0'
 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"
 pandas = "2.1.0"
 pandera = "0.20.3"
 pandera = "0.20.3"
 requests = "2.32.*"
 requests = "2.32.*"
 pydantic-settings = "^2.2.1"
 pydantic-settings = "^2.2.1"
 psycopg2 = "^2.9.9"
 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]
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"
 coverage = "^7.1.0"
 httpx = "*"
 httpx = "*"
 pytest = "^7.2.1"
 pytest = "^7.2.1"
 pytest-asyncio = "^0.20.3"
 pytest-asyncio = "^0.20.3"
-uvicorn = {extras = ["standard"], version = "*"}
+uvicorn = { extras = ["standard"], version = "*" }
 pre-commit = "^3.0.4"
 pre-commit = "^3.0.4"
 ruff = "*"
 ruff = "*"
 
 
+[tool.poetry.requires-plugins]
+poetry-plugin-export = ">=1.8"
+
 [build-system]
 [build-system]
 build-backend = "poetry.core.masonry.api"
 build-backend = "poetry.core.masonry.api"
 requires = ["poetry-core>=1.0.0"]
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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-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-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==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-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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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"
 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-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-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==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-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-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"
 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"
 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"
 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"
 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"
 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"
 sqlalchemy==2.0.43 ; python_version >= "3.11" and python_version < "4.0"