Просмотр исходного кода

feat: implement groups, organisation, roles

clovis 1 неделя назад
Родитель
Сommit
7c869a9ad9
39 измененных файлов с 4344 добавлено и 1282 удалено
  1. 8 0
      .idea/.gitignore
  2. 19 0
      .idea/bdlg2023-sms-back.iml
  3. 5 0
      .idea/codeStyles/codeStyleConfig.xml
  4. 6 0
      .idea/inspectionProfiles/profiles_settings.xml
  5. 7 0
      .idea/misc.xml
  6. 8 0
      .idea/modules.xml
  7. 6 0
      .idea/pyLspTools.xml
  8. 6 0
      .idea/ruff.xml
  9. 6 0
      .idea/vcs.xml
  10. 45 0
      alembic/versions/2026072143_add_missing_relation_f7a9944a660c.py
  11. 149 0
      alembic/versions/2026072155_implement_commission_and_organisation_285f2f9c840a.py
  12. 4 0
      app/api/api.py
  13. 124 14
      app/api/deps.py
  14. 180 0
      app/api/endpoints/organizations.py
  15. 46 48
      app/api/endpoints/project.py
  16. 32 19
      app/api/endpoints/slots.py
  17. 3 9
      app/api/endpoints/sms.py
  18. 2 4
      app/api/endpoints/tags.py
  19. 20 7
      app/api/endpoints/templates.py
  20. 50 7
      app/api/endpoints/users.py
  21. 200 0
      app/api/endpoints/volunteer_groups.py
  22. 4 9
      app/api/endpoints/volunteers.py
  23. 26 1
      app/api/utils.py
  24. 2 1
      app/main.py
  25. 145 17
      app/models.py
  26. 98 45
      app/schemas/requests.py
  27. 68 7
      app/schemas/responses.py
  28. 128 22
      app/tests/conftest.py
  29. 458 0
      app/tests/test_organizations.py
  30. 319 230
      app/tests/test_project.py
  31. 601 382
      app/tests/test_slot.py
  32. 205 48
      app/tests/test_sms_batch.py
  33. 3 3
      app/tests/test_tag.py
  34. 504 241
      app/tests/test_template.py
  35. 230 55
      app/tests/test_users.py
  36. 145 75
      app/tests/test_volunteer.py
  37. 357 0
      app/tests/test_volunteer_groups.py
  38. 124 38
      poetry.lock
  39. 1 0
      pyproject.toml

+ 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>

+ 6 - 0
.idea/pyLspTools.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RuffConfiguration">
+    <option name="enabled" value="true" />
+  </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>

+ 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)

+ 4 - 0
app/api/api.py

@@ -10,15 +10,19 @@ from app.api.endpoints import (
     tags,
     tags,
     templates,
     templates,
     sms_sender,
     sms_sender,
+    organizations,
+    volunteer_groups,
 )
 )
 
 
 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(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"])

+ 124 - 14
app/api/deps.py

@@ -1,18 +1,28 @@
 import time
 import time
-from typing import Generator
+from collections.abc import Generator
 
 
 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 UUID, 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 (
+    CommissionMember,
+    GlobalRole,
+    OrgRole,
+    Project,
+    User,
+    UserOrganization,
+)
 
 
 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 +35,128 @@ 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: str, *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
+    return session.get(CommissionMember, (commission_id, user_id)) is not None
+
+
+def require_org_role(*allowed_roles: OrgRole):
+    def dependency(
+        project_id: str = 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 require_commission_membership():
+    """For CREATE endpoints (template/slot): confirms org membership only.
+    The finer 'does this respo_commission own the commission_id in the
+    payload' check happens inside the handler via assert_commission_ownership,
+    since the dependency layer can't see the parsed body cheaply."""
+    return require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
+
+def assert_commission_ownership(
+    session: Session, current_user: User, project_id: str, 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: str = 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 == 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

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

@@ -0,0 +1,180 @@
+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, User, UserOrganization, OrgRole
+from app.schemas.requests import (
+    OrganizationRequest,
+    OrganizationMembershipCreateRequest,
+    OrganizationMembershipUpdateRequest,
+)
+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.patch("/{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()
+
+
+# ---- Membership management ----
+
+@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.patch(
+    "/{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()

+ 46 - 48
app/api/endpoints/project.py

@@ -1,4 +1,4 @@
-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
@@ -7,22 +7,26 @@ from sqlalchemy.exc import IntegrityError
 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 assert_project_exists, get_project_or_404
+from app.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
 from app.models import (
 from app.models import (
+    GlobalRole,
+    OrgRole,
     Project,
     Project,
     Slot,
     Slot,
-    SlotTemplate,
     SlotTag,
     SlotTag,
+    SlotTemplate,
     Sms,
     Sms,
     User,
     User,
     Volunteer,
     Volunteer,
 )
 )
 from app.schemas.requests import (
 from app.schemas.requests import (
+    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 +36,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 +51,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 +80,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 +88,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,7 +98,9 @@ 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"""
@@ -93,14 +113,12 @@ async def get_project(
 @router.post("/project/{project_id}", response_model=ProjectListResponse)
 @router.post("/project/{project_id}", response_model=ProjectListResponse)
 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()
@@ -111,7 +129,7 @@ async def update_project(
 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 +143,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,11 +161,14 @@ 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_contact 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,
@@ -172,7 +191,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 +201,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 +211,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 +229,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(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 +245,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 +274,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"""

+ 32 - 19
app/api/endpoints/slots.py

@@ -5,9 +5,8 @@ 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, update_object_from_payload, verify_id_list
 from app.models import (
 from app.models import (
-    Project,
     Slot,
     Slot,
     SlotTemplate,
     SlotTemplate,
     User,
     User,
@@ -23,6 +22,19 @@ from app.schemas.responses import SlotResponse
 router = APIRouter(prefix="/project/{project_id}", tags=["project"])
 router = APIRouter(prefix="/project/{project_id}", tags=["project"])
 
 
 
 
+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,
@@ -30,10 +42,7 @@ async def list_project_slots(
     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(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()
 
 
@@ -46,9 +55,9 @@ async def create_slot(
     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(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
@@ -85,15 +94,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:
@@ -132,5 +136,14 @@ async def delete_slot(
     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()

+ 3 - 9
app/api/endpoints/sms.py

@@ -5,9 +5,8 @@ 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, update_object_from_payload
 from app.models import (
 from app.models import (
-    Project,
     Sms,
     Sms,
     User,
     User,
 )
 )
@@ -24,10 +23,7 @@ async def list_project_sms(
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """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(session, project_id)
     results = session.execute(select(Sms).where(Sms.project_id == project_id))
     results = session.execute(select(Sms).where(Sms.project_id == project_id))
     return results.scalars().all()
     return results.scalars().all()
 
 
@@ -40,9 +36,7 @@ async def create_sms(
     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(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()

+ 2 - 4
app/api/endpoints/tags.py

@@ -5,7 +5,7 @@ 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 verify_id_list, get_project_or_404
 from app.models import (
 from app.models import (
     Project,
     Project,
     SlotTag,
     SlotTag,
@@ -26,9 +26,7 @@ async def list_project_tags(
     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
 
 
 
 

+ 20 - 7
app/api/endpoints/templates.py

@@ -1,11 +1,11 @@
 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, update_object_from_payload, verify_id_list
 from app.models import (
 from app.models import (
     Project,
     Project,
     SlotTag,
     SlotTag,
@@ -40,11 +40,10 @@ async def create_template(
     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(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()
@@ -78,6 +77,10 @@ async def update_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:
@@ -113,6 +116,16 @@ async def delete_template(
     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)

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

@@ -1,11 +1,11 @@
 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.core.security import get_password_hash
-from app.models import User
-from app.schemas.requests import UserCreateRequest, UserUpdatePasswordRequest
+from app.models import GlobalRole, User, UserOrganization
+from app.schemas.requests import UserCreateRequest, UserProfileUpdateRequest, UserUpdatePasswordRequest
 from app.schemas.responses import UserResponse
 from app.schemas.responses import UserResponse
 
 
 router = APIRouter()
 router = APIRouter()
@@ -16,8 +16,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 +47,26 @@ async def read_current_user(
     return current_user
     return current_user
 
 
 
 
+@router.patch("/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 +95,18 @@ 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()
-    return user
+    session.refresh(user)
+    return user

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

@@ -0,0 +1,200 @@
+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 assert_project_exists, verify_id_list  # adjust import path if different
+from app.models import OrgRole, Slot, Sms, User, Volunteer, VolunteerGroup
+from app.schemas.requests import (
+    GroupMembershipRequest,
+    GroupSmsRequest,
+    VolunteerGroupCreateRequest,
+    VolunteerGroupUpdateRequest,
+)
+from app.schemas.responses import SMSResponse, VolunteerGroupResponse, VolunteerResponse
+
+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(session, project_id)
+    results = session.execute(select(VolunteerGroup).where(VolunteerGroup.project_id == project_id))
+    return results.scalars().all()
+
+
+@router.post("/group", response_model=VolunteerGroupResponse)
+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(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.patch("/group/{group_id}", response_model=VolunteerGroupResponse)
+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}")
+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)
+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)
+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}/add-to-slot/{slot_id}", response_model=list[VolunteerResponse])
+async def add_group_to_slot(
+    project_id: UUID,
+    group_id: UUID,
+    slot_id: UUID,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_GROUPS)),
+    session: Session = Depends(deps.get_session),
+):
+    """Bulk-assigns every member of the group to the slot in one call.
+    This is a one-time copy, not a live link: adding someone to the group
+    later does NOT retroactively add them to slots already assigned."""
+    group = _get_group_or_404(session, project_id, group_id)
+
+    slot = session.get(Slot, slot_id)
+    if slot is None or slot.project_id != str(project_id):
+        raise HTTPException(status_code=404, detail="Slot not found in this project")
+
+    existing_ids = {v.id for v in slot.volunteers}
+    for volunteer in group.volunteers:
+        if volunteer.id not in existing_ids:
+            slot.volunteers.append(volunteer)
+
+    session.commit()
+    session.refresh(slot)
+    return slot.volunteers
+
+
+@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

+ 4 - 9
app/api/endpoints/volunteers.py

@@ -5,8 +5,8 @@ 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.models import Project, Slot, User, Volunteer, association_table_volunteer_slot
+from app.api.utils import assert_project_exists, update_object_from_payload, verify_id_list
+from app.models import 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
 
 
@@ -20,10 +20,7 @@ async def list_project_volunteers(
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List volunteers from project"""
     """List volunteers from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
+    assert_project_exists(session, project_id)
     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()
 
 
@@ -36,9 +33,7 @@ async def create_volunteer(
     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")
+    assert_project_exists(session, project_id)
     input_dict = new_volunteer.model_dump()
     input_dict = new_volunteer.model_dump()
 
 
     # Extract slots list from input dict
     # Extract slots list from input dict

+ 26 - 1
app/api/utils.py

@@ -1,10 +1,35 @@
 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.models import Project
+
+
+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
+
+
+def assert_project_exists(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
+
 
 
 async def verify_id_list(
 async def verify_id_list(
     session: Session,
     session: Session,

+ 2 - 1
app/main.py

@@ -26,4 +26,5 @@ 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)

+ 145 - 17
app/models.py

@@ -13,12 +13,13 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 # apply all migrations
 alembic upgrade head
 alembic upgrade head
 """
 """
-
+import enum
 from typing import Optional
 from typing import Optional
 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 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,17 +35,71 @@ 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"
+
+
 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)
+    name: Mapped[str] = mapped_column(String(128), default="")
+    phone_number: Mapped[Optional[str]] = 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["CommissionMember"]] = relationship(
+        back_populates="user", cascade="all, delete-orphan"
+    )
+
+
+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()
+    )
+    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[str] = 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()
@@ -66,7 +121,36 @@ 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()
+    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)  # e.g. "Bar", "Accueil"
+
+    templates: Mapped[list["SlotTemplate"]] = relationship(back_populates="commission")
+    members: Mapped[list["CommissionMember"]] = relationship(
+        back_populates="commission", cascade="all, delete-orphan"
+    )
+
+    @hybrid_property
+    def contacts(self) -> list[dict]:
+        """Read-only, derived from members' own profile — never stored redundantly."""
+        return [{"name": m.user.name, "phone_number": m.user.phone_number} for m in self.members]
+
+
+class CommissionMember(Base):
+    __tablename__ = "commission_members"
+    commission_id: Mapped[UUID] = mapped_column(ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True)
+    user_id: Mapped[UUID] = mapped_column(ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True)
+
+    commission: Mapped["Commission"] = relationship(back_populates="members")
+    user: Mapped["User"] = relationship(back_populates="commissions")
 
 
 association_table_volunteer_slot = Table(
 association_table_volunteer_slot = Table(
     "association_volunteer_slot",
     "association_volunteer_slot",
@@ -79,11 +163,36 @@ 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[Optional[str]] = 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 +209,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 +221,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 +240,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[Optional[UUID]] = 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 +265,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 +287,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,8 +298,10 @@ 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[Optional[str]] = mapped_column(String(), nullable=True, default=None)
+    
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
 
     tags: Mapped[list[SlotTag]] = relationship(
     tags: Mapped[list[SlotTag]] = relationship(
@@ -203,6 +318,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 +337,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,
     )
     )
@@ -235,3 +362,4 @@ class ServerStatus(Base):
     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)
+

+ 98 - 45
app/schemas/requests.py

@@ -1,8 +1,10 @@
 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
+
 
 
 class BaseRequest(BaseModel):
 class BaseRequest(BaseModel):
     # may define additional fields or config shared across requests
     # may define additional fields or config shared across requests
@@ -13,30 +15,61 @@ 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):
-    email: EmailStr
+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 ProjectUpdateRequest(BaseRequest):
     name: str
     name: str
     is_public: bool = Field(
     is_public: bool = Field(
         default=False,
         default=False,
@@ -47,7 +80,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):
@@ -72,66 +105,86 @@ class ProjectSMSBatchRequest(BaseRequest):
 
 
 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

+ 68 - 7
app/schemas/responses.py

@@ -1,11 +1,10 @@
-from enum import Enum
 from datetime import datetime
 from datetime import datetime
-from typing import Optional
+from enum import Enum
+
 from pydantic import BaseModel, ConfigDict, EmailStr
 from pydantic import BaseModel, ConfigDict, EmailStr
 
 
 
 
 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)
 
 
 
 
@@ -25,9 +24,66 @@ 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_id: str
+    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] = []
+
+
+class CommissionContactResponse(BaseResponse):
+    name: str
+    phone_number: str | None = None
+
+
+class CommissionResponse(BaseObjectResponse):
+    name: str
+    contacts: list[CommissionContactResponse] = []
+    templates_id: list[str] = []
+
+    # Commission model doesn't currently expose a hybrid `templates_id` --
+    # add one alongside `contacts` on the model (mirrors the pattern already
+    # used by SlotTemplate.tags_id / Volunteer.slots_id) if this is needed.
+
+
+class VolunteerGroupResponse(BaseObjectResponse):
+    name: str
+    color: str | None = None
+    volunteers_id: list[str] = []
 
 
 
 
 class VolunteerResponse(BaseObjectResponse):
 class VolunteerResponse(BaseObjectResponse):
@@ -37,6 +93,7 @@ class VolunteerResponse(BaseObjectResponse):
     phone_number: str
     phone_number: str
     automatic_sms: bool
     automatic_sms: bool
     slots_id: list[str] = []
     slots_id: list[str] = []
+    groups_id: list[str] = []
     comment: str
     comment: str
 
 
 
 
@@ -46,22 +103,23 @@ 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: str | None
 
 
 
 
 class SMSResponse(BaseObjectResponse):
 class SMSResponse(BaseObjectResponse):
-    volunteer_id: Optional[str]
+    volunteer_id: str | 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
+    responsible_override: str | None = None
+    commission_id: str | None = None
     tags_id: list[str] = []
     tags_id: list[str] = []
     comment: str
     comment: str
 
 
@@ -73,11 +131,13 @@ 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] = []
     is_public: bool
     is_public: bool
 
 
 
 
@@ -86,6 +146,7 @@ class ProjectListResponse(BaseResponse):
     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
 
 
 
 

+ 128 - 22
app/tests/conftest.py

@@ -1,10 +1,11 @@
 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 AsyncClient, ASGITransport
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -13,7 +14,20 @@ 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,
+    OrgRole,
+    Organization,
+    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 +45,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 +59,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 +68,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 +75,9 @@ 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 +90,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 +101,102 @@ 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 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 +206,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 +230,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 +240,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 +252,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
@@ -159,4 +265,4 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
 
 
 @pytest.fixture
 @pytest.fixture
 def default_user_headers(default_user: User):
 def default_user_headers(default_user: User):
-    return {"Authorization": f"Bearer {default_user_access_token}"}
+    return {"Authorization": f"Bearer {default_user_access_token}"}

+ 458 - 0
app/tests/test_organizations.py

@@ -0,0 +1,458 @@
+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.patch(
+            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.patch(
+            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.patch(
+            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.patch(
+            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.patch(
+            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.patch(
+            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.patch(
+            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.patch(
+            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

+ 319 - 230
app/tests/test_project.py

@@ -1,240 +1,329 @@
 import uuid
 import uuid
+
+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, 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"},
+from app.models import GlobalRole, Organization, OrgRole, Project, Slot, Volunteer
+from app.tests.conftest import default_organization_id, default_project_id, default_project_name
+
+pytestmark = pytest.mark.asyncio
+
+ALL_ORG_ROLES = [OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION]
+
+
+class TestListProject:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.get(app.url_path_for("list_project"))
+        assert response.status_code == 401
+
+    @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 = Organization(id=str(uuid.uuid4()), name="Other Org")
+        session.add(other_org)
+        session.commit()
+
+        _, headers = make_org_user(role=None)  # authenticated, no membership anywhere
+        response = await client.get(app.url_path_for("list_project"), headers=headers)
+        assert response.status_code == 200
+        assert response.json() == []
+
+
+class TestGetProject:
+    async def test_requires_auth(self, client: AsyncClient, default_project: Project):
+        response = await client.get(app.url_path_for("get_project", project_id=default_project_id))
+        print(response.text)
+        assert response.status_code == 401
+
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 200),
+            (OrgRole.RESPO_COMMISSION, 200),
+            (None, 403),
+        ],
     )
     )
-    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"},
+    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:
+    async def test_requires_auth(
+        self, client: AsyncClient, default_organization: Organization, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("create_project"),
+            json={"name": "Coucou", "organization_id": default_organization_id},
+        )
+        assert response.status_code == 401
+        assert session.execute(select(Project)).scalars().first() is None
+
+    @pytest.mark.parametrize(
+        "role, expected_status",
+        [
+            (OrgRole.ORG_ADMIN, 200),
+            (OrgRole.RESPO_BENEVOLE, 403),
+            (OrgRole.RESPO_COMMISSION, 403),
+            (None, 403),
+        ],
     )
     )
-    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},
+    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),
+        ],
     )
     )
-    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,
+    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_requires_auth(
+        self, client: AsyncClient, default_public_project: Project, session: Session
+    ):
+        response = await client.post(
+            app.url_path_for("update_project", project_id=default_project_id),
+            json={"name": "Coucou 2"},
+        )
+        assert response.status_code == 401
+        assert session.get(Project, default_project_id).name == default_project_name
+
+    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),
+        ],
     )
     )
-    assert response.status_code == 422
+    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_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        response = await client.delete(
+            app.url_path_for("delete_project", project_id=default_project_id)
+        )
+        assert response.status_code == 401
+
+    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
+        )
+
+    async def test_nonexistent_project_returns_404(self, client: AsyncClient, make_org_user):
+        """require_org_role looks the project up before the handler runs, so a
+        missing project now 404s -- this differs from the pre-refactor behavior
+        where DELETE on a nonexistent id silently returned 200."""
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.delete(
+            app.url_path_for("delete_project", project_id=uuid.uuid4()), headers=headers
+        )
+        assert response.status_code == 404

+ 601 - 382
app/tests/test_slot.py

@@ -1,425 +1,644 @@
-from datetime import datetime, timedelta, timezone
-import uuid
+from datetime import UTC, datetime, timedelta
+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 Project, Slot, SlotTemplate, Volunteer
+from app.models import Commission, CommissionMember, OrgRole, Project, Slot, SlotTemplate, Volunteer
 from app.tests.conftest import (
 from app.tests.conftest import (
     default_project_id,
     default_project_id,
     default_slot_id,
     default_slot_id,
-    default_volunteer_id,
     default_template_id,
     default_template_id,
+    default_volunteer_id,
 )
 )
 
 
+pytestmark = pytest.mark.asyncio
 
 
-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
+@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)
+    session.add(CommissionMember(commission_id=commission.id, user_id=user.id))
+    session.commit()
+    return commission, user, headers
 
 
-    # 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,
+@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
     )
     )
-    assert response.status_code == 404
+    session.add(template)
+    session.commit()
+    return template
 
 
-    # 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,
+
+@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,
     )
     )
-    assert response.status_code == 404
+    session.add(template)
+    session.commit()
+    return template
+
 
 
-    # Test normal payload
+class TestListProjectSlots:
+    async def test_requires_auth(self, client: AsyncClient, 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
+
+    async def test_project_not_found(
+        self, client: AsyncClient, default_user_headers: dict, default_user_org_membership
+    ):
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id=uuid4()), headers=default_user_headers
+        )
+        assert response.status_code == 404
 
 
-    for k, v in payload.items():
+    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
+
+
+class TestCreateSlot:
+    async def test_requires_auth(self, client: AsyncClient):
+        response = await client.post(app.url_path_for("create_slot", project_id=default_project_id))
+        assert response.status_code == 401
+
+    async def test_project_not_found(
+        self, client: AsyncClient, default_user_headers: dict, default_user_org_membership
+    ):
+        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(
         response = await client.post(
-            app.url_path_for(
-                "update_slot",
-                project_id=default_project_id,
-                slot_id=default_slot_id,
-            ),
-            json={k: v},
+            app.url_path_for("create_slot", project_id=uuid4()),
+            json=payload,
+            headers=default_user_headers,
+        )
+        assert response.status_code == 404
+
+    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,
             headers=default_user_headers,
         )
         )
         assert response.status_code == 200
         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
+        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
 
 
-    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
+    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
 
 
-    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
+class TestUpdateSlot:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        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
+
+    async def test_project_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=uuid4(), slot_id=default_slot_id),
+            json={"title": "x"},
+            headers=default_user_headers,
+        )
+        assert response.status_code == 404
+
+    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
 
 
-    session.refresh(template)
-    assert len(template.slots) == 1
+        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": 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,
-    )
+        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
 
 
-    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(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(
-        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,
-    )
+        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]
 
 
-    assert response.status_code == 200
-    session.refresh(volunteer)
-    assert volunteer is not None
-    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
 
 
-    # An invalid volunteer list
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
+        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,
             project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": [str(uuid.uuid4())]},
-        headers=default_user_headers,
-    )
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
 
 
-    assert response.status_code == 400
+        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()
 
 
-    # An invalid volunteer list
-    response = await client.post(
-        app.url_path_for(
-            "update_slot",
+        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,
             project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        json={"volunteers": ["not uuid str"]},
-        headers=default_user_headers,
-    )
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.id,
+        )
+        session.add(slot)
+        session.commit()
 
 
-    assert response.status_code == 422
+        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
 
 
 
 
-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",
+class TestDeleteSlot:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        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
+
+    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,
             project_id=default_project_id,
-            slot_id=default_slot_id,
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+            template_id=commission_template.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",
+        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,
             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
+            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

+ 205 - 48
app/tests/test_sms_batch.py

@@ -5,7 +5,17 @@ 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,
+    CommissionMember,
+    Project,
+    Slot,
+    SlotTemplate,
+    Sms,
+    User,
+    Volunteer,
+    OrgRole,
+)
 from app.tests.conftest import default_sms_id, default_volunteer_id
 from app.tests.conftest import default_sms_id, default_volunteer_id
 
 
 
 
@@ -14,11 +24,15 @@ def get_arthur(session) -> Volunteer:
     arthur = result.scalars().first()
     arthur = result.scalars().first()
     assert arthur is not None
     assert arthur is not None
     return arthur
     return arthur
-
+@pytest.fixture()
+def user_headers(make_org_user) -> dict:
+    _,headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+    return headers
+@pytest.fixture()
 
 
 async def test_create_sms_batch_bad_input(
 async def test_create_sms_batch_bad_input(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
 ):
 ):
@@ -31,7 +45,7 @@ async def test_create_sms_batch_bad_input(
 
 
 async def test_basic(
 async def test_basic(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
 ):
 ):
@@ -44,10 +58,9 @@ async def test_basic(
     slot.volunteers.append(get_arthur(session))
     slot.volunteers.append(get_arthur(session))
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -63,7 +76,7 @@ async def test_basic(
 
 
 async def test_multiple_volunteer(
 async def test_multiple_volunteer(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
 ):
 ):
@@ -84,13 +97,11 @@ async def test_multiple_volunteer(
             automatic_sms=True,
             automatic_sms=True,
         )
         )
     )
     )
-
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -99,16 +110,14 @@ async def test_multiple_volunteer(
             (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
             (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
         )
         )
     )
     )
-    count_sms = 0
-    for _ in result.fetchall():
-        count_sms += 1
+    count_sms = sum(1 for _ in result.fetchall())
     assert count_sms == 2
     assert count_sms == 2
 
 
 
 
 @pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
 @pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
 async def test_volunteer_settings(
 async def test_volunteer_settings(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
     automatic_sms: bool,
     automatic_sms: bool,
@@ -125,10 +134,9 @@ async def test_volunteer_settings(
     slot.volunteers.append(arthur)
     slot.volunteers.append(arthur)
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -143,12 +151,10 @@ async def test_volunteer_settings(
 
 
 async def test_only_future_slot(
 async def test_only_future_slot(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
 ):
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
     slot = Slot(
     slot = Slot(
         project_id=default_public_project.id,
         project_id=default_public_project.id,
         title="replanter excalibure",
         title="replanter excalibure",
@@ -156,14 +162,11 @@ async def test_only_future_slot(
         ending_time=datetime.now() + timedelta(minutes=60),
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     )
     slot.volunteers.append(get_arthur(session))
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -179,14 +182,12 @@ async def test_only_future_slot(
 @pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
 @pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
 async def test_time_delta(
 async def test_time_delta(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
     delta_t: int,
     delta_t: int,
     expected,
     expected,
 ):
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
     slot = Slot(
     slot = Slot(
         project_id=default_public_project.id,
         project_id=default_public_project.id,
         title="replanter excalibure",
         title="replanter excalibure",
@@ -194,14 +195,11 @@ async def test_time_delta(
         ending_time=datetime.now() + timedelta(minutes=60),
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     )
     slot.volunteers.append(get_arthur(session))
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
         json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -233,14 +231,15 @@ starting_time = datetime.now() + timedelta(minutes=30)
 )
 )
 async def test_content_no_template(
 async def test_content_no_template(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
     template: str,
     template: str,
     expected: str,
     expected: str,
 ):
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
+    """No SlotTemplate attached at all -- {respo}/{description} placeholders
+    are left untouched, since the replace only happens when slot.template
+    is not None. Unaffected by the commission/override change."""
     slot = Slot(
     slot = Slot(
         project_id=default_public_project.id,
         project_id=default_public_project.id,
         title="replanter excalibure",
         title="replanter excalibure",
@@ -248,14 +247,11 @@ async def test_content_no_template(
         ending_time=datetime.now() + timedelta(minutes=60),
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     )
     slot.volunteers.append(get_arthur(session))
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": template},
         json={"template": template},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -277,27 +273,23 @@ async def test_content_no_template(
         ("{prenom}", "Arthur"),
         ("{prenom}", "Arthur"),
         ("{prenom} {nom}", "Arthur Pandragon"),
         ("{prenom} {nom}", "Arthur Pandragon"),
         ("{debut}", starting_time.strftime("%Hh%M")),
         ("{debut}", starting_time.strftime("%Hh%M")),
-        (
-            "{titre} {description}, {respo}",
-            "replanter excalibure rendre l'épee à son caillou, dieu",
-        ),
     ],
     ],
 )
 )
-async def test_content_with_template(
+async def test_content_with_template_non_respo_placeholders(
     client: AsyncClient,
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     session: Session,
     default_public_project: Project,
     default_public_project: Project,
     template: str,
     template: str,
     expected: str,
     expected: str,
 ):
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
+    """Placeholders unrelated to {respo} behave the same regardless of how
+    the contact is resolved -- kept separate from the {respo}-specific cases
+    below since those need commission/override setup."""
     slot_template = SlotTemplate(
     slot_template = SlotTemplate(
         project_id=default_public_project.id,
         project_id=default_public_project.id,
         title="replanter excalibure",
         title="replanter excalibure",
         description="rendre l'épee à son caillou",
         description="rendre l'épee à son caillou",
-        responsible_contact="dieu",
         place="gros caillou",
         place="gros caillou",
     )
     )
     slot = Slot(
     slot = Slot(
@@ -312,10 +304,9 @@ async def test_content_with_template(
     session.add(slot)
     session.add(slot)
     session.commit()
     session.commit()
 
 
-    # créer un sms simple
     response = await client.post(
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": template},
         json={"template": template},
     )
     )
     assert response.status_code == 200
     assert response.status_code == 200
@@ -327,3 +318,169 @@ async def test_content_with_template(
     sms = result.scalars().first()
     sms = result.scalars().first()
     assert sms is not None
     assert sms is not None
     assert sms.content == expected
     assert sms.content == expected
+
+
+async def test_respo_placeholder_uses_override_when_set(
+    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
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == "replanter excalibure rendre l'épee à son caillou, dieu"
+
+
+async def test_respo_placeholder_derives_from_commission_members(
+    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()
+    session.add_all(
+        [
+            CommissionMember(commission_id=commission.id, user_id=member1.id),
+            CommissionMember(commission_id=commission.id, user_id=member2.id),
+        ]
+    )
+    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 intentionally left unset
+    )
+    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
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == (
+        "replanter excalibure rendre l'épee à son caillou, "
+        "Merlin : 0601020304, Morgane : 0605060708"
+    )
+
+
+async def test_respo_placeholder_empty_when_no_commission_or_override(
+    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 is only for 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",
+    )
+    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
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == "replanter excalibure, "

+ 3 - 3
app/tests/test_tag.py

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
 
 
 from app.main import app
 from app.main import app
 from app.models import Project, Slot, SlotTag, SlotTemplate
 from app.models import Project, Slot, SlotTag, SlotTemplate
-from app.tests.conftest import default_project_id, default_tag_id
+from app.tests.conftest import default_project_id, default_tag_id, default_organization_id
 
 
 
 
 async def test_read_list_project_tags(
 async def test_read_list_project_tags(
@@ -127,7 +127,7 @@ async def test_create_tag_with_template(
     session.add(template)
     session.add(template)
     session.commit()
     session.commit()
 
 
-    project = Project(name="second project", is_public=False)
+    project = Project(organization_id=default_organization_id,name="second project", is_public=False)
     session.add(project)
     session.add(project)
     session.commit()
     session.commit()
     session.refresh(project)
     session.refresh(project)
@@ -204,7 +204,7 @@ async def test_update_tag_fail_template(
 ):
 ):
     tag = session.get(SlotTag, default_tag_id)
     tag = session.get(SlotTag, default_tag_id)
     assert tag is not None
     assert tag is not None
-    project = Project(name="other project", is_public=False)
+    project = Project(organization_id=default_organization_id,name="other project", is_public=False)
     template = SlotTemplate(title="template 1")
     template = SlotTemplate(title="template 1")
     project.templates.append(template)
     project.templates.append(template)
     session.add(template)
     session.add(template)

+ 504 - 241
app/tests/test_template.py

@@ -1,250 +1,513 @@
 from uuid import uuid4
 from uuid import uuid4
-from httpx import AsyncClient
+
 import pytest
 import pytest
-from sqlalchemy import select
+from httpx import AsyncClient
 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
-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
+from app.models import Commission, CommissionMember, OrgRole, Project, Slot, SlotTemplate
+from app.tests.conftest import default_slot_id, default_tag_id, default_template_id
+
+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)
+    session.add(CommissionMember(commission_id=commission.id, user_id=user.id))
+    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
+
+
+class TestCreateTemplate:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "1st template"},
+        )
+        assert response.status_code == 401
+
+    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
+
+    async def test_project_not_found(
+        self,
+        client: AsyncClient,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for("create_template", project_id=uuid4()),
+            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_override": "echo"},
+            {"title": "1st template", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
+            {"title": "1st template", "comment": "55"},
+        ],
     )
     )
-    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,
+    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
+
+    @pytest.mark.parametrize("role", (None, OrgRole.RESPO_COMMISSION))
+    async def test_respo_ok(
+        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_template", project_id=default_public_project.id),
+            json={"title": "No commission"},
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+
+class TestUpdateTemplate:
+    async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        response = await client.post(
+            app.url_path_for(
+                "update_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            json={"title": "x"},
+        )
+        assert response.status_code == 401
+
+    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
+
+    async def test_project_not_found(
+        self,
+        client: AsyncClient,
+        default_user_headers: dict,
+        default_user_org_membership,
+    ):
+        response = await client.post(
+            app.url_path_for(
+                "update_template", project_id=uuid4(), template_id=default_template_id
+            ),
+            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}),
+        ],
     )
     )
-    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,
+    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"},
+        ],
     )
     )
-    slot = session.get(Slot, default_slot_id)
-    assert slot is not None
-    slot.template_id = default_template_id
-    session.commit()
+    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_requires_auth(self, client: AsyncClient, default_public_project: Project):
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            )
+        )
+        assert response.status_code == 401
+
+    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(url, headers=default_user_headers)
-    assert response.status_code == 200
-    slot = session.get(Slot, default_slot_id)
-    assert slot is not None
+        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

+ 230 - 55
app/tests/test_users.py

@@ -1,60 +1,235 @@
+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, OrgRole, User, UserOrganization,Organization
+from app.tests.conftest import default_user_email, default_user_password, default_organization_id
+
+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.patch(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.patch(
+            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.patch(
+            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.patch(
+            app.url_path_for("update_current_user_profile"),
+            headers=default_user_headers,
+            json={"name": "Geralt", "phone_number": "0601020304"},
+        )
+        response = await client.patch(
+            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

+ 145 - 75
app/tests/test_volunteer.py

@@ -1,4 +1,6 @@
 import uuid
 import uuid
+
+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
@@ -7,88 +9,156 @@ from app.main import app
 from app.models import Project, Slot, Sms, Volunteer
 from app.models import Project, Slot, Sms, Volunteer
 from app.tests.conftest import (
 from app.tests.conftest import (
     default_project_id,
     default_project_id,
-    default_volunteer_id,
     default_slot_id,
     default_slot_id,
+    default_volunteer_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
+class TestListVolunteer:
+    async def test_read_list_not_authenticated(
+        self,
+        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
 
 
-    # Test normal payload
-    response = await client.post(
-        app.url_path_for("create_volunteer", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
+    @pytest.mark.parametrize(
+        "project_id,status_code",
+        [(uuid.uuid4(), 404), ("pas un uuid valid", 422)],
     )
     )
-    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
+    async def test_read_list_fails(
+        self,
+        client: AsyncClient,
+        default_user_headers: dict,
+        default_public_project: Project,
+        make_org_user,
+        project_id,
+        status_code,
+    ):
+        _, headers = make_org_user()
+        response = await client.get(
+            app.url_path_for("list_project_volunteers", project_id=project_id),
+            headers=headers,
+        )
+        assert response.status_code == status_code
 
 
-    # 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_real_project(
+        self, 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),
+            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
+
+
+class TestCreateVolunteer:
+    async def test_create_volunteer_without_auth(
+        self,
+        client: AsyncClient,
+    ):
+        # Test without authentication
+        response = await client.post(
+            app.url_path_for("create_volunteer", project_id=default_project_id)
+        )
+        assert response.status_code == 401
+
+    async def test_create_volunteer_invalid_project_id(
+        self,
+        client: AsyncClient,
+        default_user_headers: dict,
+    ):
+        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
+
+    async def test_create_volunteer_invalid_payload(
+        self,
+        client: AsyncClient,
+        default_user_headers: dict,
+    ):
+        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=default_user_headers,
+        )
+        assert response.status_code == 422
+
+    async def test_create_volunteer_valid_payload(
+        self,
+        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",
+        }
+        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
+
+    async def test_create_volunteer_with_slots(
+        self,
+        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",
+            "slots": [default_slot_id],
+        }
+        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
+        result = session.execute(
+            select(Volunteer).where(Volunteer.project_id == default_project_id)
+        )
+        volunteers = result.scalars().all()
+        assert len(volunteers) > 1
+        assert len(volunteers[0].slots) == 1
 
 
 
 
 async def test_create_volunteer_comment(
 async def test_create_volunteer_comment(

+ 357 - 0
app/tests/test_volunteer_groups.py

@@ -0,0 +1,357 @@
+import uuid
+from datetime import datetime, timedelta
+
+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, Organization, Project, Slot, Volunteer, VolunteerGroup
+from app.core.session import session as session_maker
+
+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
+
+
+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.patch(
+            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 TestAddGroupToSlot:
+    async def test_bulk_assigns_all_group_members(
+        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))
+        slot = Slot(
+            project_id=default_project.id,
+            title="Garde du Graal",
+            starting_time=datetime.now() + timedelta(hours=1),
+            ending_time=datetime.now() + timedelta(hours=2),
+        )
+        session.add(slot)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "add_group_to_slot", project_id=default_project.id, group_id=default_group.id, slot_id=slot.id
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 200
+        ids = [v["id"] for v in response.json()]
+        assert sorted(ids) == sorted([v1.id, v2.id])
+
+    async def test_slot_from_other_project_not_found(
+        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 3", is_public=False, organization_id=other_org.id)
+        session.add(other_project)
+        session.commit()
+        stray_slot = Slot(
+            project_id=other_project.id, title="Stray slot",
+            starting_time=datetime.now(), ending_time=datetime.now() + timedelta(hours=1),
+        )
+        session.add(stray_slot)
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "add_group_to_slot",
+                project_id=default_project.id, group_id=default_group.id, slot_id=stray_slot.id,
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 404
+
+
+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 !"

+ 124 - 38
poetry.lock

@@ -1,4 +1,4 @@
-# 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]]
 [[package]]
 name = "alembic"
 name = "alembic"
@@ -6,6 +6,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 +26,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 +38,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"},
@@ -55,6 +58,7 @@ version = "4.3.0"
 description = "Modern password hashing for your software and your servers"
 description = "Modern password hashing for your software and your servers"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
 files = [
     {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"},
     {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_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"},
@@ -119,6 +123,7 @@ 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 +135,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 +233,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 +245,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 +334,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 +349,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 +362,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 +455,7 @@ files = [
 ]
 ]
 
 
 [package.extras]
 [package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
 
 
 [[package]]
 [[package]]
 name = "cryptography"
 name = "cryptography"
@@ -450,6 +463,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 +522,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 +540,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 +552,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 +565,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 +573,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 +589,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 +611,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 +623,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 +634,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 +645,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 +656,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 +667,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 +676,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 +687,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 +704,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 +716,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 +738,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 +794,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 +807,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 +819,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 +834,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 +849,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 +861,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 +881,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 +952,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 +964,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 +976,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 +988,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 +1034,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 +1046,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 +1076,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 +1105,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 +1142,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 +1163,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 +1180,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 +1196,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 +1215,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 +1235,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 +1248,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 +1263,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 +1271,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 +1383,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 +1407,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 +1428,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 +1449,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 +1468,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 +1483,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 +1498,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 +1513,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 +1525,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"},
@@ -1521,6 +1588,7 @@ 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 +1606,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 +1638,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 +1650,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 +1662,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 +1758,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 +1777,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 +1792,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 +1805,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 +1821,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 +1836,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 +1848,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 +1866,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 +1879,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 +1892,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 +1945,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 +1958,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 +1966,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 +2085,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 +2164,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 +2250,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 = "9c322cdbbd3c6e7b17a2e9f3a77ea28ee4cddabc4d5753423568d15b140270d2"

+ 1 - 0
pyproject.toml

@@ -19,6 +19,7 @@ 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"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"
 coverage = "^7.1.0"