Browse Source

feat: protect all API for X-organisations requests

clovis 1 tuần trước cách đây
mục cha
commit
d488660724

+ 8 - 4
app/api/endpoints/slots.py

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
 from app.api import deps
 from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.models import (
+    OrgRole,
     Slot,
     SlotTemplate,
     User,
@@ -21,6 +22,9 @@ from app.schemas.responses import SlotResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
 
 def assert_template_from_commission(
     session: Session, current_user: User, project_id: UUID, template_id: UUID
@@ -38,7 +42,7 @@ def assert_template_from_commission(
 @router.get("/slots", response_model=list[SlotResponse])
 async def list_project_slots(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
@@ -51,7 +55,7 @@ async def list_project_slots(
 async def create_slot(
     project_id: UUID,
     new_slot: SlotCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new slot to the project"""
@@ -86,7 +90,7 @@ async def update_slot(
     project_id: UUID,
     slot_id: UUID,
     new_slot: SlotUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a slot from the project"""
@@ -132,7 +136,7 @@ async def update_slot(
 async def delete_slot(
     project_id: UUID,
     slot_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""

+ 8 - 4
app/api/endpoints/sms.py

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
 from app.api import deps
 from app.api.utils import assert_project_exists_or_404, update_object_from_payload
 from app.models import (
+    OrgRole,
     Sms,
     User,
 )
@@ -15,11 +16,14 @@ from app.schemas.responses import SMSResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "sms"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
 
 @router.get("/sms", response_model=list[SMSResponse])
 async def list_project_sms(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List sms from project"""
@@ -32,7 +36,7 @@ async def list_project_sms(
 async def create_sms(
     project_id: UUID,
     new_sms: SmsCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new to the project"""
@@ -48,7 +52,7 @@ async def update_sms(
     project_id: UUID,
     sms_id: UUID,
     new_sms: SmsUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update an sms from the project"""
@@ -65,7 +69,7 @@ async def update_sms(
 async def delete_sms(
     project_id: UUID,
     sms_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a sms from the project"""

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

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

+ 10 - 6
app/api/endpoints/tags.py

@@ -5,8 +5,9 @@ from sqlalchemy import delete
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.api.utils import verify_id_list, get_project_or_404
+from app.api.utils import get_project_or_404, verify_id_list
 from app.models import (
+    OrgRole,
     Project,
     SlotTag,
     SlotTemplate,
@@ -18,11 +19,14 @@ from app.schemas.responses import SlotResponse, TagResponse
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project", "tag"])
 
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+
 
 @router.get("/tags", response_model=list[TagResponse])
 async def list_project_tags(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
@@ -34,7 +38,7 @@ async def list_project_tags(
 async def create_tag(
     project_id: UUID,
     payload: TagCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new tag to the project"""
@@ -68,7 +72,7 @@ async def update_tag(
     project_id: UUID,
     tag_id: UUID,
     payload: TagUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a tag"""
@@ -108,7 +112,7 @@ async def update_tag(
 async def list_tagged_slot(
     project_id: UUID,
     tag_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """list all slot associated with a given tag"""
@@ -126,7 +130,7 @@ async def list_tagged_slot(
 async def delete_tag(
     project_id: UUID,
     tag_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""

+ 14 - 9
app/api/endpoints/templates.py

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
 from app.api import deps
 from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.models import (
+    OrgRole,
     Project,
     SlotTag,
     SlotTemplate,
@@ -16,13 +17,17 @@ from app.models import (
 from app.schemas.requests import TemplateCreateRequest, TemplateUpdateRequest
 from app.schemas.responses import TemplateResponse
 
-router = APIRouter()
+router = APIRouter(
+    prefix="/project/{project_id}",
+)
+READ_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
+WRITE_ROLES = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
 
 
-@router.get("/project/{project_id}/templates", response_model=list[TemplateResponse])
+@router.get("/templates", response_model=list[TemplateResponse])
 async def list_project_templates(
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """List slots from project"""
@@ -32,11 +37,11 @@ async def list_project_templates(
     return p.templates
 
 
-@router.post("/project/{project_id}/template", response_model=TemplateResponse)
+@router.post("/template", response_model=TemplateResponse)
 async def create_template(
     project_id: UUID,
     payload: TemplateCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Create a new template to the project"""
@@ -65,12 +70,12 @@ async def create_template(
     return template
 
 
-@router.post("/project/{project_id}/template/{template_id}", response_model=TemplateResponse)
+@router.post("/template/{template_id}", response_model=TemplateResponse)
 async def update_template(
     project_id: UUID,
     template_id: UUID,
     payload: TemplateUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Update a template"""
@@ -108,11 +113,11 @@ async def update_template(
     return template
 
 
-@router.delete("/project/{project_id}/template/{template_id}")
+@router.delete("/template/{template_id}")
 async def delete_template(
     project_id: UUID,
     template_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""

+ 9 - 5
app/api/endpoints/users.py

@@ -5,7 +5,11 @@ from sqlalchemy.orm import Session, selectinload
 from app.api import deps
 from app.core.security import get_password_hash
 from app.models import GlobalRole, User, UserOrganization
-from app.schemas.requests import UserCreateRequest, UserProfileUpdateRequest, UserUpdatePasswordRequest
+from app.schemas.requests import (
+    UserCreateRequest,
+    UserProfileUpdateRequest,
+    UserUpdatePasswordRequest,
+)
 from app.schemas.responses import UserResponse
 
 router = APIRouter()
@@ -21,9 +25,9 @@ async def list_users(
     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()
+        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:
@@ -109,4 +113,4 @@ async def register_new_user(
     session.add(user)
     session.commit()
     session.refresh(user)
-    return user
+    return user

+ 3 - 3
app/core/security.py

@@ -1,4 +1,4 @@
-"""Black-box security shortcuts to generate JWT tokens and password hashing and verifcation."""
+"""Black-box security shortcuts to generate JWT tokens and password hashing and verification."""
 
 import time
 
@@ -74,7 +74,7 @@ def generate_access_token_response(subject: str | int):
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verifies plain and hashed password matches
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     return PWD_CONTEXT.verify(plain_password, hashed_password)
@@ -83,7 +83,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
 def get_password_hash(password: str) -> str:
     """Creates hash from password
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     return PWD_CONTEXT.hash(password)

+ 3 - 3
app/create_sms_batch.py

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

+ 4 - 3
app/initial_data.py

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

+ 38 - 16
app/models.py

@@ -13,8 +13,8 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 alembic upgrade head
 """
+
 import enum
-from typing import Optional
 import uuid
 from datetime import datetime
 
@@ -39,6 +39,7 @@ class GlobalRole(str, enum.Enum):
     SUPER_ADMIN = "super_admin"
     USER = "user"
 
+
 class OrgRole(str, enum.Enum):
     ORG_ADMIN = "org_admin"
     RESPO_BENEVOLE = "respo_benevole"
@@ -51,7 +52,7 @@ class User(Base):
     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)
+    phone_number: Mapped[str | None] = mapped_column(String(24), nullable=True)
     global_role: Mapped[GlobalRole] = mapped_column(
         SAEnum(GlobalRole, name="global_role"), default=GlobalRole.USER, nullable=False
     )
@@ -73,17 +74,23 @@ class Organization(Base):
     )
     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")
+    projects: Mapped[list["Project"]] = relationship(
+        back_populates="organization", cascade="all, delete-orphan"
+    )
     memberships: Mapped[list["UserOrganization"]] = relationship(
-        back_populates="organization", cascade="all, delete-orphan",
+        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)
+    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
     )
@@ -96,7 +103,7 @@ class UserOrganization(Base):
 class Project(Base):
     __tablename__ = "projects"
     id: Mapped[UUID] = uid_column()
-    organization_id: Mapped[str] = mapped_column(
+    organization_id: Mapped[UUID] = mapped_column(
         ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
     )
     organization: Mapped["Organization"] = relationship(back_populates="projects")
@@ -121,11 +128,15 @@ 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")
+    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()
@@ -146,12 +157,17 @@ class Commission(Base):
 
 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_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",
     Base.metadata,
@@ -170,6 +186,7 @@ association_table_volunteer_group = Table(
     Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
 )
 
+
 class VolunteerGroup(Base):
     __tablename__ = "volunteer_groups"
     id: Mapped[UUID] = uid_column()
@@ -180,15 +197,17 @@ class VolunteerGroup(Base):
         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
+    color: Mapped[str | None] = mapped_column(String(16), nullable=True)  # for UI chips later
 
     volunteers: Mapped[list["Volunteer"]] = relationship(
         secondary=association_table_volunteer_group, back_populates="groups"
     )
+
     @hybrid_property
     def volunteers_id(self) -> list[str]:
         return [v.id for v in self.volunteers]
 
+
 class Volunteer(Base):
     __tablename__ = "volunteers"
     id: Mapped[UUID] = uid_column()
@@ -240,7 +259,7 @@ class Slot(Base):
         secondary=association_table_volunteer_slot, back_populates="slots"
     )
 
-    template_id: Mapped[Optional[UUID]] = mapped_column(
+    template_id: Mapped[UUID | None] = mapped_column(
         ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
     )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
@@ -298,10 +317,12 @@ class SlotTemplate(Base):
     title: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     place: Mapped[str] = mapped_column(String(), default="")
-    commission_id: Mapped[str | None] = mapped_column(ForeignKey("commissions.id", ondelete="SET NULL"),nullable=True)
+    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)
-    
+    responsible_override: Mapped[str | None] = mapped_column(String(), nullable=True, default=None)
+
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
     tags: Mapped[list[SlotTag]] = relationship(
@@ -359,7 +380,8 @@ class ServerStatus(Base):
     __tablename__ = "server_status"
     # Use a fixed ID to ensure we only ever have one row
     id = Column(Integer, primary_key=True, default=1)
-    updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now())
+    updated_at = Column(
+        DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now()
+    )
     host = Column(String, nullable=False)
     user_agent = Column(String)
-

+ 38 - 8
app/tests/conftest.py

@@ -5,20 +5,19 @@ from uuid import uuid4
 
 import pytest
 import pytest_asyncio
-from httpx import AsyncClient, ASGITransport
+from httpx import ASGITransport, AsyncClient
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
 from app.core import config, security
 from app.core.session import engine
 from app.core.session import session as session_maker
-
 from app.main import app
 from app.models import (
     Base,
     GlobalRole,
-    OrgRole,
     Organization,
+    OrgRole,
     Project,
     Slot,
     SlotTag,
@@ -75,9 +74,7 @@ async def session(test_db_setup_sessionmaker) -> AsyncGenerator[Session, None]:
 
 @pytest_asyncio.fixture(scope="session")
 async def client() -> AsyncGenerator[AsyncClient, None]:
-    async with AsyncClient(
-        transport=ASGITransport(app=app), base_url="http://test"
-    ) as client:
+    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
         yield client
 
 
@@ -117,6 +114,37 @@ def default_organization(test_db_setup_sessionmaker) -> Organization:
         return org
 
 
+@pytest.fixture
+def other_org_user(session: Session):
+    """A user who is org_admin of a DIFFERENT organization entirely --
+    distinct from make_org_user(role=None), which has no membership at all.
+    This confirms role checks are scoped per-organization, not just
+    'has some role somewhere'."""
+    other_org = Organization(id=uuid4(), name="Other Org")
+    session.add(other_org)
+    session.commit()
+
+    from app.core import security
+
+    user_id = uuid4()
+    user = User(
+        id=user_id,
+        email=f"{user_id}@test.com",
+        hashed_password="x",
+        name="Outsider",
+    )
+    session.add(user)
+    session.commit()
+    session.add(
+        UserOrganization(user_id=user.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN)
+    )
+    session.commit()
+
+    token = security.create_jwt_token(str(user_id), 60 * 60 * 24, refresh=False)[0]
+    headers = {"Authorization": f"Bearer {token}"}
+    return user, headers
+
+
 @pytest.fixture
 def default_user_org_membership(
     default_user: User, default_organization: Organization
@@ -132,7 +160,9 @@ def default_user_org_membership(
         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
+                user_id=default_user.id,
+                organization_id=default_organization.id,
+                role=OrgRole.ORG_ADMIN,
             )
             db.add(new_membership)
             db.commit()
@@ -265,4 +295,4 @@ def default_public_project(default_organization: Organization) -> 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}"}

+ 50 - 7
app/tests/test_project.py

@@ -39,13 +39,14 @@ class TestListProject:
         assert len(response.json()) == 1
 
     async def test_excludes_other_orgs_project(
-        self, client: AsyncClient, default_project: Project, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        make_org_user,
+        session: Session,
+        other_org_user,
     ):
-        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
+        _, headers = other_org_user
         response = await client.get(app.url_path_for("list_project"), headers=headers)
         assert response.status_code == 200
         assert response.json() == []
@@ -87,6 +88,16 @@ class TestGetProject:
         )
         assert response.status_code == status
 
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.delete(
+            app.url_path_for("get_project", project_id=default_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
 
 class TestGetPublicProject:
     async def test_missing_or_private_returns_404(
@@ -188,6 +199,17 @@ class TestCreateProject:
         )
         assert response.status_code == 400
 
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("create_project"),
+            json={"name": "Coucou", "organization_id": default_organization_id},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
 
 class TestUpdateProject:
     @pytest.mark.parametrize(
@@ -221,7 +243,7 @@ class TestUpdateProject:
             assert project.name == "Coucou"
 
     async def test_requires_auth(
-        self, client: AsyncClient, default_public_project: Project, session: Session
+        self, client: AsyncClient, default_project: Project, session: Session
     ):
         response = await client.post(
             app.url_path_for("update_project", project_id=default_project_id),
@@ -230,6 +252,17 @@ class TestUpdateProject:
         assert response.status_code == 401
         assert session.get(Project, default_project_id).name == default_project_name
 
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("update_project", project_id=default_project_id),
+            json={"name": "Coucou 2"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
     async def test_validation_error(
         self, client: AsyncClient, default_public_project: Project, make_org_user
     ):
@@ -297,6 +330,16 @@ class TestDeleteProject:
         )
         assert response.status_code == 401
 
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("delete_project", project_id=default_project_id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
     async def test_cascades_to_volunteers_and_slots(
         self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
     ):

+ 87 - 1
app/tests/test_slot.py

@@ -7,7 +7,15 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Commission, CommissionMember, OrgRole, 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,
@@ -121,6 +129,22 @@ class TestListProjectSlots:
         assert response.status_code == 200
         assert len(response.json()) == 1
 
+    async def test_cannot_look_other_org_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        session: Session,
+        commission_and_member,
+        other_org_user,
+    ):
+        """An admin of a project belonging to a DIFFERENT organization cannot access the project."""
+        _, headers = other_org_user
+        response = await client.get(
+            app.url_path_for("list_project_slots", project_id=default_public_project.id),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
 
 class TestCreateSlot:
     async def test_requires_auth(self, client: AsyncClient):
@@ -273,6 +297,28 @@ class TestCreateSlot:
         )
         assert response.status_code == 403
 
+    async def test_cannot_create_other_org_slot(
+        self,
+        client: AsyncClient,
+        default_public_project: Project,
+        session: Session,
+        commission_and_member,
+        other_org_user,
+    ):
+        """An admin of a project belonging to a DIFFERENT organization cannot access the project."""
+        _, headers = other_org_user
+        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(),
+            },
+            headers=headers,
+        )
+        assert response.status_code == 403
+
 
 class TestUpdateSlot:
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
@@ -531,6 +577,32 @@ class TestUpdateSlot:
         )
         assert response.status_code == 403
 
+    async def test_cannot_update_other_org_slot(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        session: Session,
+        commission_and_member,
+        other_org_user,
+    ):
+        """An admin of a project belonging to a DIFFERENT organization cannot access the project."""
+        _, headers = other_org_user
+        slot = Slot(
+            project_id=default_project.id,
+            title="Bar shift",
+            starting_time=datetime.now(),
+            ending_time=datetime.now() + timedelta(hours=1),
+        )
+        session.add(slot)
+        session.commit()
+
+        response = await client.post(
+            app.url_path_for("update_slot", project_id=default_project_id, slot_id=slot.id),
+            json={"title": "Bar shift renamed"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
 
 class TestDeleteSlot:
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
@@ -539,6 +611,20 @@ class TestDeleteSlot:
         )
         assert response.status_code == 401
 
+    async def test_forbidden_other_org(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.delete(
+            app.url_path_for(
+                "delete_slot",
+                project_id=default_project_id,
+                slot_id=default_slot_id,
+            ),
+            headers=headers,
+        )
+        assert response.status_code == 403
+
     async def test_invalid_slot_id(
         self,
         client: AsyncClient,

+ 359 - 252
app/tests/test_sms.py

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

+ 506 - 435
app/tests/test_sms_batch.py

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

+ 424 - 294
app/tests/test_tag.py

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

+ 40 - 3
app/tests/test_template.py

@@ -43,6 +43,17 @@ class TestCreateTemplate:
         )
         assert response.status_code == 401
 
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for("create_template", project_id=default_public_project.id),
+            json={"title": "1st template"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
     async def test_validation_error(
         self,
         client: AsyncClient,
@@ -146,15 +157,13 @@ class TestCreateTemplate:
         )
         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)
+        _, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
         response = await client.post(
             app.url_path_for("create_template", project_id=default_public_project.id),
             json={"title": "No commission"},
@@ -175,6 +184,21 @@ class TestUpdateTemplate:
         )
         assert response.status_code == 401
 
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        _, headers = other_org_user
+        response = await client.post(
+            app.url_path_for(
+                "update_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            json={"title": "x"},
+            headers=headers,
+        )
+        assert response.status_code == 403
+
     async def test_invalid_template_id_format(
         self,
         client: AsyncClient,
@@ -399,6 +423,19 @@ class TestDeleteTemplate:
         )
         assert response.status_code == 401
 
+    async def test_other_org_member_forbidden(
+        self, client: AsyncClient, default_public_project: Project, other_org_user
+    ):
+        response = await client.delete(
+            app.url_path_for(
+                "delete_template",
+                project_id=default_public_project.id,
+                template_id=default_template_id,
+            ),
+            headers=other_org_user[1],
+        )
+        assert response.status_code == 403
+
     async def test_invalid_ids(
         self,
         client: AsyncClient,

+ 31 - 20
app/tests/test_users.py

@@ -6,19 +6,18 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import GlobalRole, OrgRole, User, UserOrganization,Organization
-from app.tests.conftest import default_user_email, default_user_password, default_organization_id
+from app.models import GlobalRole, Organization, OrgRole, User, UserOrganization
+from app.tests.conftest import default_organization_id, default_user_email, default_user_password
 
 pytestmark = pytest.mark.asyncio
 
+
 class TestListUsers:
     async def test_requires_auth(self, client: AsyncClient):
         response = await client.get(app.url_path_for("list_users"))
         assert response.status_code == 401
 
-    async def test_sees_colleagues_in_same_organization(
-        self, client: AsyncClient, make_org_user
-    ):
+    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
 
@@ -36,13 +35,13 @@ class TestListUsers:
         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"
-        )
+        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)
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN
+            )
         )
         session.commit()
 
@@ -74,7 +73,9 @@ class TestListUsers:
         session.add(outsider)
         session.commit()
         session.add(
-            UserOrganization(user_id=outsider.id, organization_id=other_org.id, role=OrgRole.RESPO_BENEVOLE)
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.RESPO_BENEVOLE
+            )
         )
         session.commit()
 
@@ -92,13 +93,13 @@ class TestReadCurrentUser:
         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)
+        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
-    ):
+    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
@@ -109,10 +110,14 @@ class TestReadCurrentUser:
 
 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"})
+        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):
+    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,
@@ -159,7 +164,9 @@ class TestDeleteCurrentUser:
         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):
+    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
 
@@ -168,7 +175,9 @@ class TestDeleteCurrentUser:
 
         await client.delete(app.url_path_for("delete_current_user"), headers=headers)
 
-        result = session.execute(select(UserOrganization).where(UserOrganization.user_id == user.id))
+        result = session.execute(
+            select(UserOrganization).where(UserOrganization.user_id == user.id)
+        )
         assert result.scalars().first() is None
 
 
@@ -179,7 +188,9 @@ class TestResetPassword:
         )
         assert response.status_code == 401
 
-    async def test_updates_password(self, client: AsyncClient, default_user_headers: dict, session: Session):
+    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,
@@ -232,4 +243,4 @@ class TestRegisterUser:
             app.url_path_for("register_new_user"),
             json={"email": "no-name@test.com", "password": "hunter2"},
         )
-        assert response.status_code == 422
+        assert response.status_code == 422

+ 151 - 49
app/tests/test_volunteer_groups.py

@@ -6,9 +6,9 @@ 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
+from app.main import app
+from app.models import Organization, OrgRole, Project, Slot, Volunteer, VolunteerGroup
 
 pytestmark = pytest.mark.asyncio
 
@@ -20,12 +20,20 @@ FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
 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,
+            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,
+            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()
@@ -47,7 +55,12 @@ def default_group(default_project: Project) -> VolunteerGroup:
 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
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_group: VolunteerGroup,
+        make_org_user,
+        role,
     ):
         _, headers = make_org_user(role=role)
         response = await client.get(
@@ -59,7 +72,9 @@ class TestListGroups:
         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):
+    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
@@ -67,9 +82,14 @@ class TestListGroups:
         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)
+        _, 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
@@ -89,14 +109,18 @@ class TestCreateGroup:
             json={"name": "Bar team", "color": "#00FF00"},
         )
         assert response.status_code == 200
-        group = session.execute(
-            select(VolunteerGroup).where(VolunteerGroup.name == "Bar team")
-        ).scalars().first()
+        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):
+    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),
@@ -140,11 +164,18 @@ class TestGetGroup:
 
 class TestUpdateGroup:
     async def test_updates_name_and_color(
-        self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup, make_org_user, session: Session
+        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),
+            app.url_path_for(
+                "update_group", project_id=default_project.id, group_id=default_group.id
+            ),
             headers=headers,
             json={"name": "Renamed", "color": "#0000FF"},
         )
@@ -156,8 +187,13 @@ class TestUpdateGroup:
 
 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
+        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)
@@ -167,7 +203,9 @@ class TestDeleteGroup:
 
         _, 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),
+            app.url_path_for(
+                "delete_group", project_id=default_project.id, group_id=default_group.id
+            ),
             headers=headers,
         )
         assert response.status_code == 200
@@ -177,13 +215,19 @@ class TestDeleteGroup:
 
 class TestGroupMembership:
     async def test_add_volunteers_to_group(
-        self, client: AsyncClient, default_project: Project, default_group: VolunteerGroup,
-        two_volunteers, make_org_user
+        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),
+            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]},
         )
@@ -191,18 +235,26 @@ class TestGroupMembership:
         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
+        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),
+            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),
+            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]},
         )
@@ -210,17 +262,26 @@ class TestGroupMembership:
         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
+        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)
+        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",
+            project_id=other_project.id,
+            name="Stray",
+            surname="Cat",
+            email="s@test.com",
             phone_number="0600000000",
         )
         session.add(stray_volunteer)
@@ -228,15 +289,22 @@ class TestGroupMembership:
 
         _, 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),
+            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
+        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)
@@ -248,7 +316,9 @@ class TestGroupMembership:
         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,
+                project_id=default_project.id,
+                group_id=default_group.id,
+                volunteer_id=v1.id,
             ),
             headers=headers,
         )
@@ -258,8 +328,13 @@ class TestGroupMembership:
 
 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
+        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)
@@ -277,7 +352,10 @@ class TestAddGroupToSlot:
         _, 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
+                "add_group_to_slot",
+                project_id=default_project.id,
+                group_id=default_group.id,
+                slot_id=slot.id,
             ),
             headers=headers,
         )
@@ -286,18 +364,26 @@ class TestAddGroupToSlot:
         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
+        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)
+        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),
+            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()
@@ -306,7 +392,9 @@ class TestAddGroupToSlot:
         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,
+                project_id=default_project.id,
+                group_id=default_group.id,
+                slot_id=stray_slot.id,
             ),
             headers=headers,
         )
@@ -315,8 +403,13 @@ class TestAddGroupToSlot:
 
 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
+        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)
@@ -326,7 +419,9 @@ class TestSendSmsToGroup:
 
         _, 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),
+            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} !"},
         )
@@ -335,8 +430,13 @@ class TestSendSmsToGroup:
         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
+        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)
@@ -348,10 +448,12 @@ class TestSendSmsToGroup:
 
         _, 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),
+            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 !"
+        assert response.json()[0]["content"] == "Bonjour Yvain !"

+ 24 - 54
poetry.lock

@@ -54,63 +54,33 @@ trio = ["trio (>=0.26.1)"]
 
 [[package]]
 name = "bcrypt"
-version = "4.3.0"
+version = "4.0.1"
 description = "Modern password hashing for your software and your servers"
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.6"
 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"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"},
-    {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"},
-    {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"},
-    {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"},
-    {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"},
-    {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"},
-    {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"},
-    {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"},
-    {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"},
-    {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"},
-    {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"},
-    {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"},
-    {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"},
-    {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"},
+    {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"},
+    {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"},
+    {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"},
+    {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"},
+    {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"},
+    {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"},
+    {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"},
+    {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"},
+    {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"},
+    {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"},
 ]
 
 [package.extras]
@@ -2252,4 +2222,4 @@ files = [
 [metadata]
 lock-version = "2.1"
 python-versions = "^3.11"
-content-hash = "9c322cdbbd3c6e7b17a2e9f3a77ea28ee4cddabc4d5753423568d15b140270d2"
+content-hash = "9f43a3291386ab283e510802e12aaee465e8c5bdada73e746176372de900a8cc"

+ 1 - 0
pyproject.toml

@@ -20,6 +20,7 @@ requests = "2.32.*"
 pydantic-settings = "^2.2.1"
 psycopg2 = "^2.9.9"
 ruff = "^0.16.0"
+bcrypt = "4.0.1"
 
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"