Ver Fonte

feat: implement groups, organisation, roles

clovis há 1 semana atrás
pai
commit
7c869a9ad9

+ 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,
     templates,
     sms_sender,
+    organizations,
+    volunteer_groups,
 )
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
 api_router.include_router(users.router, prefix="/users", tags=["users"])
+api_router.include_router(organizations.router, prefix="/organizations", tags=["organization"])
 api_router.include_router(project.router, tags=["project"])
 api_router.include_router(slots.router, tags=["slot"])
 api_router.include_router(tags.router, tags=["tag"])
 api_router.include_router(volunteers.router, tags=["volunteer"])
+api_router.include_router(volunteer_groups.router, tags=["volunteer_group"])
 api_router.include_router(sms.router, tags=["sms"])
 api_router.include_router(templates.router, tags=["template"])
 api_router.include_router(sms_sender.router, tags=["sms_sender"])

+ 124 - 14
app/api/deps.py

@@ -1,18 +1,28 @@
 import time
-from typing import Generator
+from collections.abc import Generator
 
 import jwt
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Path, status
 from fastapi.security import OAuth2PasswordBearer
-from sqlalchemy import select
+from sqlalchemy import UUID, exists, select
 from sqlalchemy.orm import Session
 
+from app.api.utils import get_project_organization_id
 from app.core import config, security
 from app.core.session import session
-from app.models import User
+from app.models import (
+    CommissionMember,
+    GlobalRole,
+    OrgRole,
+    Project,
+    User,
+    UserOrganization,
+)
 
 reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
 
+FULL_ACCESS_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
+
 
 def get_session() -> Generator[Session, None, None]:
     with session() as db:
@@ -25,28 +35,128 @@ async def get_current_user(
     try:
         payload = jwt.decode(token, config.settings.SECRET_KEY, algorithms=[security.JWT_ALGORITHM])
     except jwt.DecodeError:
-        raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials.",
-        )
-    # JWT guarantees payload will be unchanged (and thus valid), no errors here
+        raise HTTPException(status.HTTP_403_FORBIDDEN, "Could not validate credentials.")
+
     token_data = security.JWTTokenPayload(**payload)
 
     if token_data.refresh:
         raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials, cannot use refresh token",
+            status.HTTP_403_FORBIDDEN, "Could not validate credentials, cannot use refresh token"
         )
+
     now = int(time.time())
     if now < token_data.issued_at or now > token_data.expires_at:
         raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="Could not validate credentials, token expired or not yet valid",
+            status.HTTP_403_FORBIDDEN,
+            "Could not validate credentials, token expired or not yet valid",
         )
 
     result = session.execute(select(User).where(User.id == token_data.sub))
     user = result.scalars().first()
-
     if not user:
         raise HTTPException(status_code=404, detail="User not found.")
     return user
+
+
+def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
+    if current_user.global_role != GlobalRole.SUPER_ADMIN:
+        raise HTTPException(status.HTTP_403_FORBIDDEN, "Requires super_admin")
+    return current_user
+
+
+def _has_org_role(
+    session: Session, user_id: UUID, project_id: 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 fastapi import APIRouter, Depends, HTTPException
@@ -7,22 +7,26 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 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 (
+    GlobalRole,
+    OrgRole,
     Project,
     Slot,
-    SlotTemplate,
     SlotTag,
+    SlotTemplate,
     Sms,
     User,
     Volunteer,
 )
 from app.schemas.requests import (
+    ProjectCreateRequest,
     ProjectImportGsheetRequest,
-    ProjectRequest,
     ProjectSMSBatchRequest,
+    ProjectUpdateRequest,
 )
 from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
-from app.importData.gsheet import parseGsheet, extract_doc_uid, ParsingError
 
 router = APIRouter()
 
@@ -32,8 +36,14 @@ async def list_project(
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Get project_list"""
-    results = session.execute(select(Project))
+    """Get project list, scoped to the caller's organizations (all projects for super_admin)."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        results = session.execute(select(Project))
+    else:
+        org_ids = [m.organization_id for m in current_user.organizations]
+        if not org_ids:
+            return []
+        results = session.execute(select(Project).where(Project.organization_id.in_(org_ids)))
     return results.scalars().all()
 
 
@@ -41,18 +51,27 @@ async def list_project(
 async def list_public_project(
     session: Session = Depends(deps.get_session),
 ):
-    """Get the list of public projects"""
+    """Get the list of public projects — unchanged, stays fully public."""
     results = session.execute(select(Project).where(Project.is_public))
     return results.scalars().all()
 
 
 @router.post("/project", response_model=ProjectResponse)
 async def create_project(
-    new_project: ProjectRequest,
+    new_project: ProjectCreateRequest,
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Create a new project"""
+    """Create a new project within one of the caller's organizations."""
+    org_id = str(new_project.organization_id)
+    if current_user.global_role != GlobalRole.SUPER_ADMIN:
+        role = next(
+            (m.role for m in current_user.organizations if m.organization_id == org_id),
+            None,
+        )
+        if role != OrgRole.ORG_ADMIN:
+            raise HTTPException(403, "Only an org_admin can create projects for this organization")
+
     project = Project(**new_project.model_dump())
     session.add(project)
     try:
@@ -61,7 +80,6 @@ async def create_project(
         raise HTTPException(400, "Project name already exist")
 
     session.refresh(project)
-
     return project
 
 
@@ -70,7 +88,7 @@ async def get_public_project(
     project_id: UUID,
     session: Session = Depends(deps.get_session),
 ):
-    """Get a project that is public"""
+    """Get a project that is public — unchanged, stays fully public."""
     result = session.get(Project, project_id)
     if (result is None) or not result.is_public:
         raise HTTPException(status_code=404, detail="Project not found")
@@ -80,7 +98,9 @@ async def get_public_project(
 @router.get("/project/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(
+        deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+    ),
     session: Session = Depends(deps.get_session),
 ):
     """Get a project"""
@@ -93,14 +113,12 @@ async def get_project(
 @router.post("/project/{project_id}", response_model=ProjectListResponse)
 async def update_project(
     project_id: UUID,
-    edit_project: ProjectRequest,
-    current_user: User = Depends(deps.get_current_user),
+    edit_project: ProjectUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
     session: Session = Depends(deps.get_session),
 ):
     """Edit project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    p = get_project_or_404(session, project_id)
     p.name = edit_project.name
     p.is_public = edit_project.is_public
     session.commit()
@@ -111,7 +129,7 @@ async def update_project(
 async def update_project_from_gsheet(
     project_id: UUID,
     gsheet: ProjectImportGsheetRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
     session: Session = Depends(deps.get_session),
 ):
     """Edit project name"""
@@ -125,13 +143,11 @@ async def update_project_from_gsheet(
         p.volunteers = []
         p.tags = []
         p.templates = []
-    # Parse the gsheets
     try:
         data = parseGsheet(doc_id, gsheet.satursday_date)
     except ParsingError as exc:
         raise HTTPException(status_code=422, detail=str(exc))
 
-    # Create the volunteer list
     volunteer_map: dict[str, Volunteer] = {}
     for _, row in data.contact.iterrows():
         volunteer = Volunteer(
@@ -145,11 +161,14 @@ async def update_project_from_gsheet(
         volunteer_map[row.key] = volunteer
         session.add(volunteer)
 
-    # Create creneau templates
     template_map = {}
     tags_map = {tag.title: tag for tag in p.tags}
 
     for _, row in data.creneauData.iterrows():
+        # NOTE: responsible_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(
             project_id=project_id,
             title=row.title,
@@ -172,7 +191,6 @@ async def update_project_from_gsheet(
         session.add(template)
 
     df_planning = data.planning
-    # group planning entry per same name and timing
     date_format = "%Y/%m/%d %H:%M"
     df_planning["key"] = (
         df_planning.nom.str.strip()
@@ -183,7 +201,6 @@ async def update_project_from_gsheet(
     )
     df_slots = df_planning.groupby("key")
 
-    # Create slots
     for key in df_slots.groups.keys():
         group = df_slots.get_group(key)
         volunteers = group.benevole_nom.tolist()
@@ -194,12 +211,10 @@ async def update_project_from_gsheet(
             ending_time=group.end.iloc[0],
             required_volunteers=len(volunteers),
         )
-        # Add volunteer to slots
         for benevole_key in volunteers:
             if benevole_key in volunteer_map:
                 slot.volunteers.append(volunteer_map[benevole_key])
 
-        # add detail information if available
         template_id = group.template_id.iloc[0]
         if template_id in template_map:
             slot.template = template_map[template_id]
@@ -214,30 +229,15 @@ async def update_project_from_gsheet(
 async def create_sms_batch(
     project_id: UUID,
     sms_batch: ProjectSMSBatchRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
     session: Session = Depends(deps.get_session),
 ):
-    """Create SMS based on a template and the list of slots and volunteer associated to the project
-
-    The placeholder that can be used in the template are
-     - {titre} slot.title
-     - {description} slot.description
-     - {debut} slot.starting_time
-     - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
-     - {prenom} volunteer.name
-     - {nom} volunteer.surname
-
-    """
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-    # Get all slots
+    """(docstring unchanged)"""
+    assert_project_exists(session, project_id)
     slots = session.execute(select(Slot).where(Slot.project_id == project_id))
     sms_list = []
-    now = datetime.now(timezone.utc)
+    now = datetime.now(UTC)
     for slot in slots.scalars():
-        # Replace the slot placeholder by their value
         slot_content = (
             sms_batch.template.replace("{titre}", slot.title)
             .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
@@ -245,17 +245,15 @@ async def create_sms_batch(
         )
         if slot.template is not None:
             slot_content = slot_content.replace("{description}", slot.template.description).replace(
-                "{respo}", slot.template.responsible_contact
+                "{respo}", slot.template.effective_responsible_contact
             )
 
         sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
-        # Skip SMS that should have been send before now
         if sending_time < now:
             continue
         for volunteer in slot.volunteers:
             if not volunteer.automatic_sms:
                 continue
-            # Create a new SMS customized for each user attache to the slot
             personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
                 "{nom}", volunteer.surname
             )
@@ -276,7 +274,7 @@ async def create_sms_batch(
 @router.delete("/project/{project_id}")
 async def delete_project(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete project"""

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

@@ -5,9 +5,8 @@ from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload, verify_id_list
+from app.api.utils import assert_project_exists, update_object_from_payload, verify_id_list
 from app.models import (
-    Project,
     Slot,
     SlotTemplate,
     User,
@@ -23,6 +22,19 @@ from app.schemas.responses import SlotResponse
 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])
 async def list_project_slots(
     project_id: UUID,
@@ -30,10 +42,7 @@ async def list_project_slots(
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
+    assert_project_exists(session, project_id)
     results = session.execute(select(Slot).where(Slot.project_id == project_id))
     return results.scalars().all()
 
@@ -46,9 +55,9 @@ async def create_slot(
     session: Session = Depends(deps.get_session),
 ):
     """Create a new slot to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists(session, project_id)
+    if new_slot.template_id:
+        assert_template_from_commission(session, current_user, project_id, new_slot.template_id)
 
     input_dict = new_slot.model_dump()
     # Extract volunteer list from input dict
@@ -85,15 +94,10 @@ async def update_slot(
     if (slot is None) or (slot.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Slot not found : ")
 
-    if new_slot.template_id is not None:
-        if new_slot.template_id != "":
-            await verify_id_list(
-                session,
-                [new_slot.template_id],
-                project_id,
-                SlotTemplate,
-                "Invalid template id",
-            )
+    if new_slot.template_id:
+        assert_template_from_commission(session, current_user, project_id, new_slot.template_id)
+    if slot.template_id is not None:
+        assert_template_from_commission(session, current_user, project_id, slot.template_id)
 
     input_dict = new_slot.model_dump(exclude_unset=True)
     if "volunteers" in input_dict:
@@ -132,5 +136,14 @@ async def delete_slot(
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""
-    session.execute(delete(Slot).where((Slot.id == slot_id) & (Slot.project_id == project_id)))
+    result = session.execute(
+        select(Slot.id, Slot.template_id).where(Slot.id == slot_id, Slot.project_id == project_id)
+    ).first()
+    if result is None:
+        raise HTTPException(status_code=404, detail="Slot not found")
+
+    if result[1] is not None:
+        assert_template_from_commission(session, current_user, project_id, result[1])
+
+    session.execute(delete(Slot).where(Slot.id == slot_id))
     session.commit()

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

@@ -5,9 +5,8 @@ from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload
+from app.api.utils import assert_project_exists, update_object_from_payload
 from app.models import (
-    Project,
     Sms,
     User,
 )
@@ -24,10 +23,7 @@ async def list_project_sms(
     session: Session = Depends(deps.get_session),
 ):
     """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))
     return results.scalars().all()
 
@@ -40,9 +36,7 @@ async def create_sms(
     session: Session = Depends(deps.get_session),
 ):
     """Create a new to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists(session, project_id)
     sms = Sms(project_id=project_id, **new_sms.model_dump())
     session.add(sms)
     session.commit()

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

@@ -5,7 +5,7 @@ from sqlalchemy import delete
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import verify_id_list
+from app.api.utils import verify_id_list, get_project_or_404
 from app.models import (
     Project,
     SlotTag,
@@ -26,9 +26,7 @@ async def list_project_tags(
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    p = get_project_or_404(session, project_id)
     return p.tags
 
 

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

@@ -1,11 +1,11 @@
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import delete
+from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import update_object_from_payload, verify_id_list
+from app.api.utils import assert_project_exists, update_object_from_payload, verify_id_list
 from app.models import (
     Project,
     SlotTag,
@@ -40,11 +40,10 @@ async def create_template(
     session: Session = Depends(deps.get_session),
 ):
     """Create a new template to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
-    template = SlotTemplate(project_id=p.id, title=payload.title)
+    assert_project_exists(session, project_id)
+    if payload.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, payload.commission_id)
+    template = SlotTemplate(project_id=project_id, title=payload.title)
 
     session.add(template)
     session.commit()
@@ -78,6 +77,10 @@ async def update_template(
     template = session.get(SlotTemplate, template_id)
     if (template is None) or (template.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Template not found")
+    if template.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, template.commission_id)
+    if payload.commission_id is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, payload.commission_id)
 
     input_dict = payload.model_dump(exclude_unset=True)
     if payload.tags is not None:
@@ -113,6 +116,16 @@ async def delete_template(
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""
+    result = session.execute(
+        select(SlotTemplate.id, SlotTemplate.commission_id).where(
+            SlotTemplate.id == template_id, SlotTemplate.project_id == project_id
+        )
+    ).first()
+    if result is None:
+        raise HTTPException(status_code=404, detail="Template not found")
+    if result[1] is not None:
+        deps.assert_commission_ownership(session, current_user, project_id, result[1])
+
     session.execute(
         delete(SlotTemplate).where(
             (SlotTemplate.id == template_id) & (SlotTemplate.project_id == project_id)

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

@@ -1,11 +1,11 @@
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
 from app.core.security import get_password_hash
-from app.models import User
-from app.schemas.requests import UserCreateRequest, UserUpdatePasswordRequest
+from app.models import GlobalRole, User, UserOrganization
+from app.schemas.requests import UserCreateRequest, UserProfileUpdateRequest, UserUpdatePasswordRequest
 from app.schemas.responses import UserResponse
 
 router = APIRouter()
@@ -16,8 +16,27 @@ async def list_users(
     current_user: User = Depends(deps.get_current_user),
     session: Session = Depends(deps.get_session),
 ):
-    """Returns the list of users from the application"""
-    return session.execute(select(User)).scalars().all()
+    """Returns users. super_admin sees everyone; anyone else sees only users
+    who share at least one organization with them -- enough to find teammates
+    when adding commission members or org roles, without leaking the full
+    cross-organization user directory."""
+    if current_user.global_role == GlobalRole.SUPER_ADMIN:
+        return session.execute(
+            select(User).options(selectinload(User.organizations))
+        ).scalars().all()
+
+    org_ids = [m.organization_id for m in current_user.organizations]
+    if not org_ids:
+        return [current_user]
+
+    result = session.execute(
+        select(User)
+        .join(UserOrganization, UserOrganization.user_id == User.id)
+        .where(UserOrganization.organization_id.in_(org_ids))
+        .options(selectinload(User.organizations))
+        .distinct()
+    )
+    return result.scalars().all()
 
 
 @router.get("/me", response_model=UserResponse)
@@ -28,6 +47,26 @@ async def read_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)
 async def delete_current_user(
     current_user: User = Depends(deps.get_current_user),
@@ -56,14 +95,18 @@ async def register_new_user(
     new_user: UserCreateRequest,
     session: Session = Depends(deps.get_session),
 ):
-    """Create new user"""
+    """Create new user. Does not attach the user to any organization --
+    a super_admin must add them via POST /organizations/{id}/members
+    afterward."""
     result = session.execute(select(User).where(User.email == new_user.email))
     if result.scalars().first() is not None:
         raise HTTPException(status_code=400, detail="Cannot use this email address")
     user = User(
         email=new_user.email,
+        name=new_user.name,
         hashed_password=get_password_hash(new_user.password),
     )
     session.add(user)
     session.commit()
-    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 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.responses import VolunteerResponse
 
@@ -20,10 +20,7 @@ async def list_project_volunteers(
     session: Session = Depends(deps.get_session),
 ):
     """List volunteers from project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
-
+    assert_project_exists(session, project_id)
     results = session.execute(select(Volunteer).where(Volunteer.project_id == project_id))
     return results.scalars().all()
 
@@ -36,9 +33,7 @@ async def create_volunteer(
     session: Session = Depends(deps.get_session),
 ):
     """Create a new volunteer to the project"""
-    p = session.get(Project, project_id)
-    if p is None:
-        raise HTTPException(status_code=404, detail="Project not found")
+    assert_project_exists(session, project_id)
     input_dict = new_volunteer.model_dump()
 
     # Extract slots list from input dict

+ 26 - 1
app/api/utils.py

@@ -1,10 +1,35 @@
 from uuid import UUID
 
 from fastapi import HTTPException
-from sqlalchemy import select
+from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.sql import func
 
+from app.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(
     session: Session,

+ 2 - 1
app/main.py

@@ -26,4 +26,5 @@ app.add_middleware(
 )
 
 # Guards against HTTP Host Header attacks
-app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)
+if config.settings.ENVIRONMENT != "PYTEST":
+    app.add_middleware(TrustedHostMiddleware, allowed_hosts=config.settings.ALLOWED_HOSTS)

+ 145 - 17
app/models.py

@@ -13,12 +13,13 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 alembic upgrade head
 """
-
+import enum
 from typing import Optional
 import uuid
 from datetime import datetime
 
 from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
+from sqlalchemy import Enum as SAEnum
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -34,17 +35,71 @@ def uid_column() -> Mapped[str]:
     return mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()))
 
 
+class GlobalRole(str, enum.Enum):
+    SUPER_ADMIN = "super_admin"
+    USER = "user"
+
+class OrgRole(str, enum.Enum):
+    ORG_ADMIN = "org_admin"
+    RESPO_BENEVOLE = "respo_benevole"
+    RESPO_COMMISSION = "respo_commission"
+
+
 class User(Base):
     __tablename__ = "user_model"
-
-    id: Mapped[str] = uid_column()
+    id: Mapped[UUID] = uid_column()
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
+    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):
     __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())
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
@@ -66,7 +121,36 @@ class Project(Base):
     tags: Mapped[list["SlotTag"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
+    commissions: Mapped[list["Commission"]] = relationship(back_populates="project", cascade="delete, delete-orphan")
 
+    groups: Mapped[list["VolunteerGroup"]] = relationship(
+        back_populates="project", cascade="all, delete-orphan", passive_deletes=True
+    )
+class Commission(Base):
+    __tablename__ = "commissions"
+    id: Mapped[UUID] = uid_column()
+    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_volunteer_slot",
@@ -79,11 +163,36 @@ association_table_volunteer_slot = Table(
     Column("slot_id", ForeignKey("slots.id", ondelete="CASCADE"), primary_key=True),
 )
 
+association_table_volunteer_group = Table(
+    "association_volunteer_group",
+    Base.metadata,
+    Column("volunteer_id", ForeignKey("volunteers.id", ondelete="CASCADE"), primary_key=True),
+    Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
+)
+
+class VolunteerGroup(Base):
+    __tablename__ = "volunteer_groups"
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project: Mapped["Project"] = relationship()
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now()
+    )
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
+    color: Mapped[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):
     __tablename__ = "volunteers"
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="volunteers")
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(
@@ -100,6 +209,10 @@ class Volunteer(Base):
     comment: Mapped[str] = mapped_column(String(), default="")
     sms: Mapped[list["Sms"]] = relationship(back_populates="volunteer", cascade="all, delete")
 
+    groups: Mapped[list["VolunteerGroup"]] = relationship(
+        secondary=association_table_volunteer_group, back_populates="volunteers"
+    )
+
     @hybrid_property
     def slots_id(self) -> list[str]:
         return [s.id for s in self.slots]
@@ -108,8 +221,8 @@ class Volunteer(Base):
 class Slot(Base):
     __tablename__ = "slots"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="slots")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -127,7 +240,7 @@ class Slot(Base):
         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
     )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
@@ -152,8 +265,8 @@ association_table_template_tags = Table(
 class SlotTag(Base):
     __tablename__ = "slot_tags"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="tags")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -174,8 +287,8 @@ class SlotTag(Base):
 class SlotTemplate(Base):
     __tablename__ = "slot_templates"
 
-    id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    id: Mapped[UUID] = uid_column()
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="templates")
 
     created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -185,8 +298,10 @@ class SlotTemplate(Base):
     title: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     place: Mapped[str] = mapped_column(String(), default="")
-    responsible_contact: Mapped[str] = mapped_column(String(), default="")
-
+    commission_id: Mapped[str | None] = mapped_column(ForeignKey("commissions.id", ondelete="SET NULL"),nullable=True)
+    commission: Mapped["Commission"] = relationship(back_populates="templates")
+    responsible_override: Mapped[Optional[str]] = mapped_column(String(), nullable=True, default=None)
+    
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
     tags: Mapped[list[SlotTag]] = relationship(
@@ -203,6 +318,18 @@ class SlotTemplate(Base):
     def tags_id(self) -> list[str]:
         return [s.id for s in self.tags]
 
+    @hybrid_property
+    def effective_responsible_contact(self) -> str:
+        """What SMS/templates should actually display as {respo}."""
+        if self.responsible_override:
+            return self.responsible_override
+        if self.commission is not None:
+            contacts = self.commission.contacts  # [{"name":..., "phone_number":...}, ...]
+            return ", ".join(
+                f"{c['name']} : {c['phone_number']}" for c in contacts if c.get("phone_number")
+            )
+        return ""
+
 
 class Sms(Base):
     __tablename__ = "sms"
@@ -210,10 +337,10 @@ class Sms(Base):
     id: Mapped[str] = mapped_column(
         UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
     )
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="sms")
 
-    volunteer_id: Mapped[str] = mapped_column(
+    volunteer_id: Mapped[UUID] = mapped_column(
         ForeignKey("volunteers.id", ondelete="CASCADE", onupdate="CASCADE"),
         nullable=True,
     )
@@ -235,3 +362,4 @@ class ServerStatus(Base):
     updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now())
     host = Column(String, nullable=False)
     user_agent = Column(String)
+

+ 98 - 45
app/schemas/requests.py

@@ -1,8 +1,10 @@
 import datetime
-from typing import Literal, Optional
+from typing import Literal
 
 from pydantic import UUID4, BaseModel, EmailStr, Field
 
+from app.models import OrgRole
+
 
 class BaseRequest(BaseModel):
     # may define additional fields or config shared across requests
@@ -13,30 +15,61 @@ class RefreshTokenRequest(BaseRequest):
     refresh_token: str
 
 
-class UserUpdatePasswordRequest(BaseRequest):
+class UserCreateRequest(BaseRequest):
+    email: EmailStr
     password: str
+    name: str
 
 
-class UserCreateRequest(BaseRequest):
-    email: EmailStr
+class UserUpdatePasswordRequest(BaseRequest):
     password: str
 
 
+class UserProfileUpdateRequest(BaseRequest):
+    name: str | None = None
+    phone_number: str | None = None
+
+
+class OrganizationRequest(BaseRequest):
+    name: str
+
+
+class OrganizationMembershipCreateRequest(BaseRequest):
+    """Adds an EXISTING user (by email) to an organization with a role.
+    Does not create user accounts — see note below."""
+
+    user_id: UUID4
+    role: OrgRole
+
+
+class OrganizationMembershipUpdateRequest(BaseRequest):
+    role: OrgRole
+
+
 class SmsCreateRequest(BaseRequest):
-    volunteer_id: Optional[UUID4] = None
+    volunteer_id: UUID4 | None = None
     phone_number: str
     content: str
-    sending_time: Optional[datetime.datetime] = None
+    sending_time: datetime.datetime | None = None
 
 
 class SmsUpdateRequest(BaseRequest):
-    volunteer_id: Optional[UUID4] = None
-    phone_number: Optional[str] = None
-    content: Optional[str] = None
-    sending_time: Optional[datetime.datetime] = None
+    volunteer_id: UUID4 | None = None
+    phone_number: str | None = None
+    content: str | None = None
+    sending_time: datetime.datetime | None = None
 
 
-class ProjectRequest(BaseRequest):
+class ProjectCreateRequest(BaseRequest):
+    organization_id: UUID4
+    name: str
+    is_public: bool = Field(
+        default=False,
+        description="Allow anyone to access the information about this project without login",
+    )
+
+
+class ProjectUpdateRequest(BaseRequest):
     name: str
     is_public: bool = Field(
         default=False,
@@ -47,7 +80,7 @@ class ProjectRequest(BaseRequest):
 class ProjectImportGsheetRequest(BaseRequest):
     sheet_url: str
     satursday_date: datetime.datetime
-    erase_data: Optional[bool] = Field(default=False)
+    erase_data: bool | None = Field(default=False)
 
 
 class ProjectSMSBatchRequest(BaseRequest):
@@ -72,66 +105,86 @@ class ProjectSMSBatchRequest(BaseRequest):
 
 class VolunteerCreateRequest(BaseRequest):
     name: str
-    surname: Optional[str] = Field(default="")
+    surname: str | None = Field(default="")
     email: EmailStr
     phone_number: str
     automatic_sms: bool = Field(default=True)
-    slots: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    slots: list[UUID4] | None = None
+    comment: str | None = None
 
 
 class VolunteerUpdateRequest(BaseRequest):
-    name: Optional[str] = None
-    surname: Optional[str] = None
-    email: Optional[str] = None
-    phone_number: Optional[str] = None
-    automatic_sms: Optional[bool] = None
-    slots: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    name: str | None = None
+    surname: str | None = None
+    email: str | None = None
+    phone_number: str | None = None
+    automatic_sms: bool | None = None
+    slots: list[UUID4] | None = None
+    comment: str | None = None
+
+
+class VolunteerGroupCreateRequest(BaseRequest):
+    name: str
+    color: str | None = None
+
+
+class VolunteerGroupUpdateRequest(BaseRequest):
+    name: str
+    color: str | None = None
+
+
+class GroupMembershipRequest(BaseRequest):
+    volunteer_ids: list[UUID4]
+
+
+class GroupSmsRequest(BaseRequest):
+    content: str
 
 
 class SlotCreateRequest(BaseRequest):
     title: str
     starting_time: datetime.datetime
     ending_time: datetime.datetime
-    required_volunteers: Optional[int] = 0
-    volunteers: Optional[list[UUID4]] = None
-    template_id: Optional[UUID4] = None
+    required_volunteers: int | None = 0
+    volunteers: list[UUID4] | None = None
+    template_id: UUID4 | None = None
 
 
 class SlotUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    starting_time: Optional[datetime.datetime] = None
-    ending_time: Optional[datetime.datetime] = None
-    required_volunteers: Optional[int] = None
-    volunteers: Optional[list[UUID4]] = None
-    template_id: Optional[UUID4 | Literal[""]] = None
+    title: str | None = None
+    starting_time: datetime.datetime | None = None
+    ending_time: datetime.datetime | None = None
+    required_volunteers: int | None = None
+    volunteers: list[UUID4] | None = None
+    template_id: UUID4 | Literal[""] | None = None
 
 
 class TemplateCreateRequest(BaseRequest):
     title: str
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
-    tags: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    description: str | None = None
+    place: str | None = None
+    responsible_override: str | None = None
+    tags: list[UUID4] | None = None
+    comment: str | None = None
+    commission_id: UUID4 | None = None
 
 
 class TemplateUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
+    title: str | None = None
+    description: str | None = None
+    place: str | None = None
+    responsible_override: str | None = None
     # slots: Optional[list[UUID4]] = None
-    tags: Optional[list[UUID4]] = None
-    comment: Optional[str] = None
+    tags: list[UUID4] | None = None
+    comment: str | None = None
+    commission_id: UUID4 | None = None
 
 
 class TagCreateRequest(BaseRequest):
     title: str
-    templates: Optional[list[UUID4]] = None
+    templates: list[UUID4] | None = None
 
 
 class TagUpdateRequest(BaseRequest):
-    title: Optional[str] = None
-    templates: Optional[list[UUID4]] = None
+    title: str | None = None
+    templates: list[UUID4] | None = None

+ 68 - 7
app/schemas/responses.py

@@ -1,11 +1,10 @@
-from enum import Enum
 from datetime import datetime
-from typing import Optional
+from enum import Enum
+
 from pydantic import BaseModel, ConfigDict, EmailStr
 
 
 class BaseResponse(BaseModel):
-    # may define additional fields or config shared across responses
     model_config = ConfigDict(from_attributes=True)
 
 
@@ -25,9 +24,66 @@ class AccessTokenResponse(BaseResponse):
     refresh_token_issued_at: int
 
 
+# ---- Organization ----
+
+
+class OrganizationResponse(BaseResponse):
+    id: str
+    name: str
+
+
+class MembershipUserResponse(BaseResponse):
+    id: str
+    email: EmailStr
+    name: str
+
+
+class OrganizationMembershipResponse(BaseResponse):
+    user_id: str
+    organization_id: str
+    role: str
+    user: MembershipUserResponse
+
+
+class OrganizationDetailResponse(OrganizationResponse):
+    name: str
+    memberships: list[OrganizationMembershipResponse] = []
+
+
+class UserOrganizationResponse(BaseResponse):
+    organization_id: str
+    organization: OrganizationResponse
+    role: str
+
+
 class UserResponse(BaseResponse):
     id: str
     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):
@@ -37,6 +93,7 @@ class VolunteerResponse(BaseObjectResponse):
     phone_number: str
     automatic_sms: bool
     slots_id: list[str] = []
+    groups_id: list[str] = []
     comment: str
 
 
@@ -46,22 +103,23 @@ class SlotResponse(BaseObjectResponse):
     ending_time: datetime
     required_volunteers: int
     volunteers_id: list[str] = []
-    template_id: Optional[str]
+    template_id: str | None
 
 
 class SMSResponse(BaseObjectResponse):
-    volunteer_id: Optional[str]
+    volunteer_id: str | None
     content: str
     phone_number: str
     sending_time: datetime
-    send_time: Optional[datetime]
+    send_time: datetime | None
 
 
 class TemplateResponse(BaseObjectResponse):
     title: str
     description: str
     place: str
-    responsible_contact: str
+    responsible_override: str | None = None
+    commission_id: str | None = None
     tags_id: list[str] = []
     comment: str
 
@@ -73,11 +131,13 @@ class TagResponse(BaseObjectResponse):
 
 class ProjectResponse(BaseObjectResponse):
     name: str
+    organization_id: str
     volunteers: list[VolunteerResponse]
     sms: list[SMSResponse]
     slots: list[SlotResponse]
     tags: list[TagResponse]
     templates: list[TemplateResponse]
+    groups: list[VolunteerGroupResponse] = []
     is_public: bool
 
 
@@ -86,6 +146,7 @@ class ProjectListResponse(BaseResponse):
     created_at: datetime
     updated_at: datetime
     name: str
+    organization_id: str
     is_public: bool
 
 

+ 128 - 22
app/tests/conftest.py

@@ -1,10 +1,11 @@
 import asyncio
 from collections.abc import AsyncGenerator
 from datetime import datetime
+from uuid import uuid4
 
 import pytest
 import pytest_asyncio
-from httpx import AsyncClient
+from httpx import AsyncClient, ASGITransport
 from sqlalchemy import delete, select
 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.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_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_template_id = "dcfcf2e9-8b1d-4bbf-b44b-aa511a7fe274"
 
+default_organization_id = "a1f2e3d4-5b6c-47d8-9e0f-1a2b3c4d5e6f"
+default_organization_name = "Default Org"
+
 
 @pytest.fixture(scope="session")
 def event_loop():
@@ -42,10 +59,7 @@ def event_loop():
 
 @pytest_asyncio.fixture(scope="session")
 async def test_db_setup_sessionmaker():
-    # assert if we use TEST_DB URL for 100%
     assert config.settings.ENVIRONMENT == "PYTEST"
-
-    # always drop and create test db tables between tests session
     Base.metadata.drop_all(engine)
     Base.metadata.create_all(engine)
 
@@ -54,8 +68,6 @@ async def test_db_setup_sessionmaker():
 async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
     with session_maker() as db:
         yield db
-
-        # delete all data from all tables after test
         for name, table in Base.metadata.tables.items():
             db.execute(delete(table))
         db.commit()
@@ -63,8 +75,9 @@ async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
 
 @pytest_asyncio.fixture(scope="session")
 async def client() -> AsyncGenerator[AsyncClient, None]:
-    async with AsyncClient(app=app, base_url="http://test") as client:
-        client.headers.update({"Host": "localhost"})
+    async with AsyncClient(
+        transport=ASGITransport(app=app), base_url="http://test"
+    ) as client:
         yield client
 
 
@@ -77,6 +90,7 @@ def default_user(test_db_setup_sessionmaker) -> User:
             new_user = User(
                 email=default_user_email,
                 hashed_password=default_user_password_hash,
+                name="Geralt",
             )
             new_user.id = default_user_id
             db.add(new_user)
@@ -87,13 +101,102 @@ def default_user(test_db_setup_sessionmaker) -> User:
 
 
 @pytest.fixture
-def default_project(test_db_setup_sessionmaker) -> Project:
-    """An empty private project"""
+def default_organization(test_db_setup_sessionmaker) -> Organization:
+    """Uses session_maker() directly, like default_user/default_project, so
+    it's safe to depend on from fixtures that also use session_maker()
+    without cross-session visibility issues (same DB, autocommitted)."""
+    with session_maker() as db:
+        result = db.execute(select(Organization).where(Organization.id == default_organization_id))
+        org = result.scalars().first()
+        if org is None:
+            new_org = Organization(id=default_organization_id, name=default_organization_name)
+            db.add(new_org)
+            db.commit()
+            db.refresh(new_org)
+            return new_org
+        return org
+
+
+@pytest.fixture
+def default_user_org_membership(
+    default_user: User, default_organization: Organization
+) -> UserOrganization:
+    """default_user is org_admin of default_organization."""
+    with session_maker() as db:
+        result = db.execute(
+            select(UserOrganization).where(
+                UserOrganization.user_id == default_user.id,
+                UserOrganization.organization_id == default_organization.id,
+            )
+        )
+        membership = result.scalars().first()
+        if membership is None:
+            new_membership = UserOrganization(
+                user_id=default_user.id, organization_id=default_organization.id, role=OrgRole.ORG_ADMIN
+            )
+            db.add(new_membership)
+            db.commit()
+            db.refresh(new_membership)
+            return new_membership
+        return membership
+
+
+@pytest.fixture
+def make_org_user(default_organization: Organization):
+    """
+    Factory fixture for parametrized role tests.
+    make_org_user(role=OrgRole.RESPO_BENEVOLE) -> (user, headers), member of default_organization.
+    make_org_user(role=None)                   -> authenticated user with NO membership (tests 403 path).
+    make_org_user(global_role=GlobalRole.SUPER_ADMIN) -> bypasses org role checks entirely.
+    Always creates a brand-new user (unique email) so parametrized cases don't collide.
+    """
+
+    def _factory(
+        role: OrgRole | None = None,
+        global_role: GlobalRole = GlobalRole.USER,
+    ) -> tuple[User, dict]:
+        with session_maker() as db:
+            user_id = uuid4()
+            user = User(
+                id=user_id,
+                email=f"{user_id}@test.com",
+                hashed_password=default_user_password_hash,
+                name="Test User",
+                global_role=global_role,
+            )
+            db.add(user)
+            db.commit()
+            db.refresh(user)
+
+            if role is not None:
+                db.add(
+                    UserOrganization(
+                        user_id=user.id, organization_id=default_organization.id, role=role
+                    )
+                )
+                db.commit()
+                db.refresh(user)
+
+        token = security.create_jwt_token(str(user_id), 60 * 60 * 24, refresh=False)[0]
+        headers = {"Authorization": f"Bearer {token}"}
+        return user, headers
+
+    return _factory
+
+
+@pytest.fixture
+def default_project(default_organization: Organization) -> Project:
+    """An empty private project. Depends on default_organization so the FK
+    is always satisfied regardless of which fixtures a test happens to request."""
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         if project is None:
-            new_project = Project(name=default_project_name, is_public=False)
+            new_project = Project(
+                organization_id=default_organization.id,
+                name=default_project_name,
+                is_public=False,
+            )
             new_project.id = default_project_id
             db.add(new_project)
             db.commit()
@@ -103,16 +206,20 @@ def default_project(test_db_setup_sessionmaker) -> Project:
 
 
 @pytest.fixture
-def default_public_project(test_db_setup_sessionmaker) -> Project:
+def default_public_project(default_organization: Organization) -> Project:
     """A public project with 1 volunteer, 1 slot & 1 sms associated to."""
     with session_maker() as db:
         result = db.execute(select(Project).where(Project.id == default_project_id))
         project = result.scalars().first()
         if project is None:
-            new_project = Project(name=default_project_name, is_public=True)
+            new_project = Project(
+                organization_id=default_organization.id,
+                name=default_project_name,
+                is_public=True,
+            )
             new_project.id = default_project_id
             db.add(new_project)
-            # Create a volunteer
+
             volunteer = Volunteer(
                 project_id=default_project_id,
                 name="Arthur",
@@ -123,7 +230,7 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             volunteer.id = default_volunteer_id
             db.add(volunteer)
-            # Create a slot
+
             slot = Slot(
                 project_id=default_project_id,
                 title="être roi",
@@ -133,10 +240,11 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             slot.id = default_slot_id
             slot.volunteers.append(volunteer)
             db.add(slot)
+
             tag = SlotTag(project_id=default_project_id, title="Royal")
             tag.id = default_tag_id
             db.add(tag)
-            # Create a sms
+
             sms = Sms(
                 project_id=default_project_id,
                 content="Bonjour sir",
@@ -144,14 +252,12 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             sms.id = default_sms_id
             db.add(sms)
+
             tmp = SlotTemplate(project_id=default_project_id, title="basic template")
             tmp.id = default_template_id
             db.add(tmp)
+
             db.commit()
-            db.commit()
-            db.refresh(sms)
-            db.refresh(slot)
-            db.refresh(volunteer)
             db.refresh(new_project)
             return new_project
         return project
@@ -159,4 +265,4 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
 
 @pytest.fixture
 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 pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Slot, 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 sqlalchemy import select
 from sqlalchemy.orm import Session
 
 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 (
     default_project_id,
     default_slot_id,
-    default_volunteer_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(
-            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,
         )
         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,
-            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,
-            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,
-            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,
-            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 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
 
 
@@ -14,11 +24,15 @@ def get_arthur(session) -> Volunteer:
     arthur = result.scalars().first()
     assert arthur is not None
     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(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
 ):
@@ -31,7 +45,7 @@ async def test_create_sms_batch_bad_input(
 
 async def test_basic(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
 ):
@@ -44,10 +58,9 @@ async def test_basic(
     slot.volunteers.append(get_arthur(session))
     session.add(slot)
     session.commit()
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     assert response.status_code == 200
@@ -63,7 +76,7 @@ async def test_basic(
 
 async def test_multiple_volunteer(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
 ):
@@ -84,13 +97,11 @@ async def test_multiple_volunteer(
             automatic_sms=True,
         )
     )
-
     session.add(slot)
     session.commit()
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     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)
         )
     )
-    count_sms = 0
-    for _ in result.fetchall():
-        count_sms += 1
+    count_sms = sum(1 for _ in result.fetchall())
     assert count_sms == 2
 
 
 @pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
 async def test_volunteer_settings(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
     automatic_sms: bool,
@@ -125,10 +134,9 @@ async def test_volunteer_settings(
     slot.volunteers.append(arthur)
     session.add(slot)
     session.commit()
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     assert response.status_code == 200
@@ -143,12 +151,10 @@ async def test_volunteer_settings(
 
 async def test_only_future_slot(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
     slot = Slot(
         project_id=default_public_project.id,
         title="replanter excalibure",
@@ -156,14 +162,11 @@ async def test_only_future_slot(
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}"},
     )
     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)])
 async def test_time_delta(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
     delta_t: int,
     expected,
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
     slot = Slot(
         project_id=default_public_project.id,
         title="replanter excalibure",
@@ -194,14 +195,11 @@ async def test_time_delta(
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
     )
     assert response.status_code == 200
@@ -233,14 +231,15 @@ starting_time = datetime.now() + timedelta(minutes=30)
 )
 async def test_content_no_template(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
     template: str,
     expected: str,
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
+    """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(
         project_id=default_public_project.id,
         title="replanter excalibure",
@@ -248,14 +247,11 @@ async def test_content_no_template(
         ending_time=datetime.now() + timedelta(minutes=60),
     )
     slot.volunteers.append(get_arthur(session))
-
     session.add(slot)
     session.commit()
-
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": template},
     )
     assert response.status_code == 200
@@ -277,27 +273,23 @@ async def test_content_no_template(
         ("{prenom}", "Arthur"),
         ("{prenom} {nom}", "Arthur Pandragon"),
         ("{debut}", starting_time.strftime("%Hh%M")),
-        (
-            "{titre} {description}, {respo}",
-            "replanter excalibure rendre l'épee à son caillou, dieu",
-        ),
     ],
 )
-async def test_content_with_template(
+async def test_content_with_template_non_respo_placeholders(
     client: AsyncClient,
-    default_user_headers: dict,
+    user_headers: dict,
     session: Session,
     default_public_project: Project,
     template: str,
     expected: str,
 ):
-    # Create a slot starting in the past
-    # no sms should be issued
+    """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(
         project_id=default_public_project.id,
         title="replanter excalibure",
         description="rendre l'épee à son caillou",
-        responsible_contact="dieu",
         place="gros caillou",
     )
     slot = Slot(
@@ -312,10 +304,9 @@ async def test_content_with_template(
     session.add(slot)
     session.commit()
 
-    # créer un sms simple
     response = await client.post(
         app.url_path_for("create_sms_batch", project_id=default_public_project.id),
-        headers=default_user_headers,
+        headers=user_headers,
         json={"template": template},
     )
     assert response.status_code == 200
@@ -327,3 +318,169 @@ async def test_content_with_template(
     sms = result.scalars().first()
     assert sms is not None
     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.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(
@@ -127,7 +127,7 @@ async def test_create_tag_with_template(
     session.add(template)
     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.commit()
     session.refresh(project)
@@ -204,7 +204,7 @@ async def test_update_tag_fail_template(
 ):
     tag = session.get(SlotTag, default_tag_id)
     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")
     project.templates.append(template)
     session.add(template)

+ 504 - 241
app/tests/test_template.py

@@ -1,250 +1,513 @@
 from uuid import uuid4
-from httpx import AsyncClient
+
 import pytest
-from sqlalchemy import select
+from httpx import AsyncClient
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Slot, SlotTemplate
-from app.tests.conftest import default_slot_id, default_template_id, default_tag_id
-
-
-async def test_create_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    url = app.url_path_for("create_template", project_id=default_public_project.id)
-    response = await client.post(url, json={"title": "1st template"})
-    assert response.status_code == 401
-    response = await client.post(url, json={}, headers=default_user_headers)
-    assert response.status_code == 422
-
-    url = app.url_path_for("create_template", project_id=uuid4())
-    response = await client.post(url, json={"title": "1st template"}, headers=default_user_headers)
-    assert response.status_code == 404
-
-
-@pytest.mark.parametrize(
-    "payload",
-    [
-        {"title": "1st template"},
-        {"title": "1𝖘ҭ ṥ٥ι𝙪𝓉ìóη", "place": "echo"},
-        {"title": "1st template", "responsible_contact": "echo"},
-        {"title": "1st template", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "comment": "55"},
-    ],
-)
-async def test_create_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-):
-    url = app.url_path_for("create_template", project_id=default_public_project.id)
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, response.json()["id"])
-    assert template is not None
-    for k in ["title", "description", "place", "responsible_contact"]:
-        val = template.__getattribute__(k)
-        if k in payload:
-            assert val == payload[k]
-        else:
-            assert val == ""
-
-
-async def test_update_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    payload = {"title": "1st template"}
-    project = session.get(Project, default_public_project.id)
-    assert project is not None
-    template_id = project.templates[0].id
-    url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id="12"
-    )
-    response = await client.post(url, json=payload)
-    assert response.status_code == 401
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 422
-    url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id=uuid4()
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 404
-
-    url = app.url_path_for("update_template", project_id=uuid4(), template_id=template_id)
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 404
-
-
-@pytest.mark.parametrize(
-    "code,payload",
-    [
-        (422, {"title": [1.001, 2]}),
-        (422, {"responsible_contact": {"t": None}}),
-        (422, {"place": [1, 2]}),
-        (422, {"tags": ["1", "2"]}),
-        # (422, {"slots": "1"}),
-        (400, {"tags": [default_slot_id]}),
-        (422, {"comment": 1235}),
-    ],
-)
-async def test_update_template_fail_payload_validation(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-    code: int,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == code
-
-
-@pytest.mark.parametrize(
-    "payload",
-    [
-        {"title": "1st template"},
-        {"title": "1st template", "place": "echo"},
-        {"title": "1st template", "responsible_contact": "echo"},
-        {"title": "1st template", "description": "&é'(-è_çecho"},
-        {
-            "title": "1st template",
-            "place": "Ḽơᶉëᶆ ȋṕšᶙṁ",
-            "description": "&é'(-è_çecho",
-        },
-        {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "comment": "&é'(-è_çecho"},
-    ],
-)
-async def test_update_template(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-    payload: dict,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, default_template_id)
-    assert template is not None
-    for k in ["title", "description", "place", "responsible_contact", "comment"]:
-        val = template.__getattribute__(k)
-        if k in payload:
-            assert val == payload[k]
-        else:
-            assert val == ""
-
-
-async def test_update_template_lists(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    url = app.url_path_for(
-        "update_template",
-        project_id=default_public_project.id,
-        template_id=default_template_id,
-    )
-    payload = {"tags": [default_tag_id]}
-    response = await client.post(url, json=payload, headers=default_user_headers)
-    assert response.status_code == 200
-    template = session.get(SlotTemplate, default_template_id)
-    assert template is not None
-    assert len(template.tags) == 1
-    assert template.tags[0].id == default_tag_id
-    response = await client.post(url, json={"tags": []}, headers=default_user_headers)
-    assert response.status_code == 200
-    session.refresh(template)
-    assert len(template.tags) == 0
-
-
-async def test_delete_template_fail(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Proper deletion
-    project_id = default_public_project.id
-    # no authent
-    url = app.url_path_for(
-        "delete_template", project_id=project_id, template_id=default_template_id
+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 sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import User
-from app.tests.conftest import (
-    default_user_email,
-    default_user_id,
-    default_user_password_hash,
-)
-
-
-async def test_read_current_user(client: AsyncClient, default_user_headers):
-    response = await client.get(app.url_path_for("read_current_user"), headers=default_user_headers)
-    assert response.status_code == 200
-    assert response.json() == {
-        "id": default_user_id,
-        "email": default_user_email,
-    }
-
-
-async def test_delete_current_user(client: AsyncClient, default_user_headers, session: Session):
-    response = await client.delete(
-        app.url_path_for("delete_current_user"), headers=default_user_headers
-    )
-    assert response.status_code == 204
-    result = session.execute(select(User).where(User.id == default_user_id))
-    user = result.scalars().first()
-    assert user is None
-
-
-async def test_reset_current_user_password(
-    client: AsyncClient, default_user_headers, session: Session
-):
-    response = await client.post(
-        app.url_path_for("reset_current_user_password"),
-        headers=default_user_headers,
-        json={"password": "testxxxxxx"},
-    )
-    assert response.status_code == 200
-    result = session.execute(select(User).where(User.id == default_user_id))
-    user = result.scalars().first()
-    assert user is not None
-    assert user.hashed_password != default_user_password_hash
-
-
-async def test_register_new_user(client: AsyncClient, default_user_headers, session: Session):
-    response = await client.post(
-        app.url_path_for("register_new_user"),
-        headers=default_user_headers,
-        json={
-            "email": "qwe@example.com",
-            "password": "asdasdasd",
-        },
-    )
-    assert response.status_code == 200
-    result = session.execute(select(User).where(User.email == "qwe@example.com"))
-    user = result.scalars().first()
-    assert user is not None
+from app.models import GlobalRole, 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 pytest
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy.orm import Session
@@ -7,88 +9,156 @@ from app.main import app
 from app.models import Project, Slot, Sms, Volunteer
 from app.tests.conftest import (
     default_project_id,
-    default_volunteer_id,
     default_slot_id,
+    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(

+ 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]]
 name = "alembic"
@@ -6,6 +6,7 @@ version = "1.16.5"
 description = "A database migration tool for SQLAlchemy."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"},
     {file = "alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e"},
@@ -25,6 +26,7 @@ version = "0.7.0"
 description = "Reusable constraint types to use with typing.Annotated"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
     {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -36,6 +38,7 @@ version = "4.10.0"
 description = "High-level concurrency and networking framework on top of asyncio or Trio"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
     {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
@@ -55,6 +58,7 @@ version = "4.3.0"
 description = "Modern password hashing for your software and your servers"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"},
     {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"},
@@ -119,6 +123,7 @@ version = "2025.8.3"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
     {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
     {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@@ -130,6 +135,8 @@ version = "2.0.0"
 description = "Foreign Function Interface for Python calling C code."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\""
 files = [
     {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
     {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -226,6 +233,7 @@ version = "3.4.0"
 description = "Validate configuration and produce human readable error messages."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
@@ -237,6 +245,7 @@ version = "3.4.3"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
     {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
@@ -325,6 +334,7 @@ version = "8.2.1"
 description = "Composable command line interface toolkit"
 optional = false
 python-versions = ">=3.10"
+groups = ["dev"]
 files = [
     {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
     {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
@@ -339,6 +349,8 @@ version = "0.4.6"
 description = "Cross-platform colored terminal text."
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
 files = [
     {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -350,6 +362,7 @@ version = "7.10.6"
 description = "Code coverage measurement for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
     {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
@@ -442,7 +455,7 @@ files = [
 ]
 
 [package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
 
 [[package]]
 name = "cryptography"
@@ -450,6 +463,7 @@ version = "46.0.1"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 optional = false
 python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+groups = ["main"]
 files = [
     {file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"},
     {file = "cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080"},
@@ -508,7 +522,7 @@ files = [
 ]
 
 [package.dependencies]
-cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""}
+cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
 
 [package.extras]
 docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
@@ -526,6 +540,7 @@ version = "0.4.0"
 description = "Distribution utilities"
 optional = false
 python-versions = "*"
+groups = ["dev"]
 files = [
     {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
     {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
@@ -537,6 +552,7 @@ version = "2.8.0"
 description = "DNS toolkit"
 optional = false
 python-versions = ">=3.10"
+groups = ["main"]
 files = [
     {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
     {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
@@ -549,7 +565,7 @@ doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"]
 doq = ["aioquic (>=1.2.0)"]
 idna = ["idna (>=3.10)"]
 trio = ["trio (>=0.30)"]
-wmi = ["wmi (>=1.5.1)"]
+wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
 
 [[package]]
 name = "email-validator"
@@ -557,6 +573,7 @@ version = "2.3.0"
 description = "A robust email address syntax and deliverability validation library."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
     {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
@@ -572,6 +589,7 @@ version = "0.116.2"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"},
     {file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"},
@@ -593,6 +611,7 @@ version = "3.19.1"
 description = "A platform independent file lock."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
     {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
@@ -604,6 +623,8 @@ version = "3.2.4"
 description = "Lightweight in-process concurrent programming"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
+markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
 files = [
     {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
     {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
@@ -613,6 +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-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
     {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
+    {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
+    {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
     {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
     {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
     {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@@ -622,6 +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-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
     {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
+    {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
+    {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
     {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
     {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
     {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@@ -631,6 +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-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
     {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
+    {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
+    {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
     {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
     {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
     {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@@ -640,6 +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-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
     {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
+    {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
+    {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
     {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
     {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
     {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@@ -647,6 +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_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
     {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
+    {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
+    {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
     {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
     {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
     {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@@ -656,6 +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-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
     {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
+    {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
+    {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
     {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
     {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
     {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@@ -671,6 +704,7 @@ version = "0.16.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
     {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -682,6 +716,7 @@ version = "1.0.9"
 description = "A minimal low-level HTTP client."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
     {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -703,6 +738,7 @@ version = "0.6.4"
 description = "A collection of framework independent HTTP protocol utils."
 optional = false
 python-versions = ">=3.8.0"
+groups = ["dev"]
 files = [
     {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"},
     {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"},
@@ -758,6 +794,7 @@ version = "0.28.1"
 description = "The next generation HTTP client."
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
     {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -770,7 +807,7 @@ httpcore = "==1.*"
 idna = "*"
 
 [package.extras]
-brotli = ["brotli", "brotlicffi"]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
 cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (==1.*)"]
@@ -782,6 +819,7 @@ version = "2.6.14"
 description = "File identification library for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"},
     {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"},
@@ -796,6 +834,7 @@ version = "3.10"
 description = "Internationalized Domain Names in Applications (IDNA)"
 optional = false
 python-versions = ">=3.6"
+groups = ["main", "dev"]
 files = [
     {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
     {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@@ -810,6 +849,7 @@ version = "2.1.0"
 description = "brain-dead simple config-ini parsing"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
     {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
@@ -821,6 +861,7 @@ version = "1.3.10"
 description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
     {file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
@@ -840,6 +881,7 @@ version = "3.0.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
     {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
@@ -910,6 +952,7 @@ version = "1.10"
 description = "Multiple argument dispatching."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "multimethod-1.10-py3-none-any.whl", hash = "sha256:afd84da9c3d0445c84f827e4d63ad42d17c6d29b122427c6dee9032ac2d2a0d4"},
     {file = "multimethod-1.10.tar.gz", hash = "sha256:daa45af3fe257f73abb69673fd54ddeaf31df0eb7363ad6e1251b7c9b192d8c5"},
@@ -921,6 +964,7 @@ version = "1.1.0"
 description = "Type system extensions for programs checked with the mypy type checker."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
     {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
@@ -932,6 +976,7 @@ version = "1.9.1"
 description = "Node.js virtual environment builder"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
 files = [
     {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
     {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
@@ -943,6 +988,7 @@ version = "1.26.4"
 description = "Fundamental package for array computing in Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
     {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
@@ -988,6 +1034,7 @@ version = "25.0"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.8"
+groups = ["main", "dev"]
 files = [
     {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
     {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
@@ -999,6 +1046,7 @@ version = "2.1.0"
 description = "Powerful data structures for data analysis, time series, and statistics"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pandas-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40dd20439ff94f1b2ed55b393ecee9cb6f3b08104c2c40b0cb7186a2f0046242"},
     {file = "pandas-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f38e4fedeba580285eaac7ede4f686c6701a9e618d8a857b138a126d067f2f"},
@@ -1028,16 +1076,16 @@ pytz = ">=2020.1"
 tzdata = ">=2022.1"
 
 [package.extras]
-all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.05.0)", "gcsfs (>=2022.05.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.05.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.03.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"]
-aws = ["s3fs (>=2022.05.0)"]
+all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.5.0)", "gcsfs (>=2022.5.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.5.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.3.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"]
+aws = ["s3fs (>=2022.5.0)"]
 clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"]
 compression = ["zstandard (>=0.17.0)"]
-computation = ["scipy (>=1.8.1)", "xarray (>=2022.03.0)"]
+computation = ["scipy (>=1.8.1)", "xarray (>=2022.3.0)"]
 consortium-standard = ["dataframe-api-compat (>=0.1.7)"]
 excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pyxlsb (>=1.0.9)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)"]
 feather = ["pyarrow (>=7.0.0)"]
-fss = ["fsspec (>=2022.05.0)"]
-gcp = ["gcsfs (>=2022.05.0)", "pandas-gbq (>=0.17.5)"]
+fss = ["fsspec (>=2022.5.0)"]
+gcp = ["gcsfs (>=2022.5.0)", "pandas-gbq (>=0.17.5)"]
 hdf5 = ["tables (>=3.7.0)"]
 html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"]
 mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"]
@@ -1057,6 +1105,7 @@ version = "0.20.3"
 description = "A light-weight and flexible data validation and testing tool for statistical data objects."
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "pandera-0.20.3-py3-none-any.whl", hash = "sha256:0c251a3947d8c6d7a0e26602e78fa425268bc9f448f3a0fded85249b158a65c2"},
     {file = "pandera-0.20.3.tar.gz", hash = "sha256:d8542a68493d2dfe3226c186cc145be8d27535d7edf396178e93811515fe3470"},
@@ -1093,6 +1142,7 @@ version = "1.7.4"
 description = "comprehensive password hashing framework supporting over 30 schemes"
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
     {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
@@ -1113,6 +1163,7 @@ version = "4.4.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
     {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
@@ -1129,6 +1180,7 @@ version = "1.6.0"
 description = "plugin and hook calling mechanisms for python"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
     {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -1144,6 +1196,7 @@ version = "3.8.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
     {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
@@ -1162,6 +1215,7 @@ version = "2.9.10"
 description = "psycopg2 - Python-PostgreSQL Database Adapter"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"},
     {file = "psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a"},
@@ -1181,6 +1235,8 @@ version = "2.23"
 description = "C parser in Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
 files = [
     {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
     {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
@@ -1192,6 +1248,7 @@ version = "2.11.9"
 description = "Data validation using Python type hints"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"},
     {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"},
@@ -1206,7 +1263,7 @@ typing-inspection = ">=0.4.0"
 
 [package.extras]
 email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata"]
+timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
 
 [[package]]
 name = "pydantic-core"
@@ -1214,6 +1271,7 @@ version = "2.33.2"
 description = "Core functionality for Pydantic validation and serialization"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
     {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
@@ -1325,6 +1383,7 @@ version = "2.10.1"
 description = "Settings management using Pydantic"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"},
     {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"},
@@ -1348,6 +1407,7 @@ version = "2.10.1"
 description = "JSON Web Token implementation in Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
     {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
@@ -1368,6 +1428,7 @@ version = "7.4.4"
 description = "pytest: simple powerful testing with Python"
 optional = false
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
     {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
     {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
@@ -1388,6 +1449,7 @@ version = "0.20.3"
 description = "Pytest support for asyncio"
 optional = false
 python-versions = ">=3.7"
+groups = ["dev"]
 files = [
     {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
     {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"},
@@ -1406,6 +1468,7 @@ version = "2.9.0.post0"
 description = "Extensions to the standard Python datetime module"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
     {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
     {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@@ -1420,6 +1483,7 @@ version = "1.1.1"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
     {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"},
@@ -1434,6 +1498,7 @@ version = "0.0.9"
 description = "A streaming multipart parser for Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"},
     {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"},
@@ -1448,6 +1513,7 @@ version = "2025.2"
 description = "World timezone definitions, modern and historical"
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
     {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
@@ -1459,6 +1525,7 @@ version = "6.0.2"
 description = "YAML parser and emitter for Python"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
     {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
@@ -1521,6 +1588,7 @@ version = "2.32.5"
 description = "Python HTTP for Humans."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
     {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -1538,30 +1606,30 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "ruff"
-version = "0.13.0"
+version = "0.16.0"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
-files = [
-    {file = "ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004"},
-    {file = "ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9"},
-    {file = "ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991"},
-    {file = "ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945"},
-    {file = "ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823"},
-    {file = "ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768"},
-    {file = "ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb"},
-    {file = "ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e"},
-    {file = "ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60"},
+groups = ["main", "dev"]
+files = [
+    {file = "ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e"},
+    {file = "ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522"},
+    {file = "ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b"},
+    {file = "ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09"},
+    {file = "ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed"},
+    {file = "ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb"},
+    {file = "ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472"},
+    {file = "ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d"},
+    {file = "ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982"},
 ]
 
 [[package]]
@@ -1570,6 +1638,7 @@ version = "1.17.0"
 description = "Python 2 and 3 compatibility utilities"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
 files = [
     {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
     {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
@@ -1581,6 +1650,7 @@ version = "1.3.1"
 description = "Sniff out which async library your code is running under"
 optional = false
 python-versions = ">=3.7"
+groups = ["main", "dev"]
 files = [
     {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
     {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@@ -1592,6 +1662,7 @@ version = "2.0.43"
 description = "Database Abstraction Library"
 optional = false
 python-versions = ">=3.7"
+groups = ["main"]
 files = [
     {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"},
     {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"},
@@ -1687,6 +1758,7 @@ version = "0.48.0"
 description = "The little ASGI library that shines."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
     {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
@@ -1705,6 +1777,7 @@ version = "4.4.4"
 description = "Run-time type checker for Python"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e"},
     {file = "typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74"},
@@ -1719,10 +1792,12 @@ version = "4.15.0"
 description = "Backported and Experimental Type Hints for Python 3.9+"
 optional = false
 python-versions = ">=3.9"
+groups = ["main", "dev"]
 files = [
     {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
     {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
 ]
+markers = {dev = "python_version < \"3.13\""}
 
 [[package]]
 name = "typing-inspect"
@@ -1730,6 +1805,7 @@ version = "0.9.0"
 description = "Runtime inspection utilities for typing module."
 optional = false
 python-versions = "*"
+groups = ["main"]
 files = [
     {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
     {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
@@ -1745,6 +1821,7 @@ version = "0.4.1"
 description = "Runtime typing introspection tools"
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
     {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
@@ -1759,6 +1836,7 @@ version = "2025.2"
 description = "Provider of IANA time zone data"
 optional = false
 python-versions = ">=2"
+groups = ["main"]
 files = [
     {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
     {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
@@ -1770,13 +1848,14 @@ version = "2.5.0"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 optional = false
 python-versions = ">=3.9"
+groups = ["main"]
 files = [
     {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
     {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
 ]
 
 [package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
 h2 = ["h2 (>=4,<5)"]
 socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 zstd = ["zstandard (>=0.18.0)"]
@@ -1787,6 +1866,7 @@ version = "0.35.0"
 description = "The lightning-fast ASGI server."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
     {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
@@ -1799,12 +1879,12 @@ h11 = ">=0.8"
 httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
-uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
 [package.extras]
-standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
 
 [[package]]
 name = "uvloop"
@@ -1812,6 +1892,8 @@ version = "0.21.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
 optional = false
 python-versions = ">=3.8.0"
+groups = ["dev"]
+markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
 files = [
     {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"},
     {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"},
@@ -1863,6 +1945,7 @@ version = "20.34.0"
 description = "Virtual Python Environment builder"
 optional = false
 python-versions = ">=3.8"
+groups = ["dev"]
 files = [
     {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"},
     {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"},
@@ -1875,7 +1958,7 @@ platformdirs = ">=3.9.1,<5"
 
 [package.extras]
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
 
 [[package]]
 name = "watchfiles"
@@ -1883,6 +1966,7 @@ version = "1.1.0"
 description = "Simple, modern and high performance file watching and code reload in python."
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
     {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
@@ -2001,6 +2085,7 @@ version = "15.0.1"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 optional = false
 python-versions = ">=3.9"
+groups = ["dev"]
 files = [
     {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
     {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
@@ -2079,6 +2164,7 @@ version = "1.17.3"
 description = "Module for decorators, wrappers and monkey patching."
 optional = false
 python-versions = ">=3.8"
+groups = ["main"]
 files = [
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
     {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
@@ -2164,6 +2250,6 @@ files = [
 ]
 
 [metadata]
-lock-version = "2.0"
+lock-version = "2.1"
 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.*"
 pydantic-settings = "^2.2.1"
 psycopg2 = "^2.9.9"
+ruff = "^0.16.0"
 
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"