Prechádzať zdrojové kódy

feat: protect all API for X-organisations requests

clovis 1 týždeň pred
rodič
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 import deps
 from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.api.utils import assert_project_exists_or_404, update_object_from_payload, verify_id_list
 from app.models import (
 from app.models import (
+    OrgRole,
     Slot,
     Slot,
     SlotTemplate,
     SlotTemplate,
     User,
     User,
@@ -21,6 +22,9 @@ from app.schemas.responses import SlotResponse
 
 
 router = APIRouter(prefix="/project/{project_id}", tags=["project"])
 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(
 def assert_template_from_commission(
     session: Session, current_user: User, project_id: UUID, template_id: UUID
     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])
 @router.get("/slots", response_model=list[SlotResponse])
 async def list_project_slots(
 async def list_project_slots(
     project_id: UUID,
     project_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """List slots from project"""
     """List slots from project"""
@@ -51,7 +55,7 @@ async def list_project_slots(
 async def create_slot(
 async def create_slot(
     project_id: UUID,
     project_id: UUID,
     new_slot: SlotCreateRequest,
     new_slot: SlotCreateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Create a new slot to the project"""
     """Create a new slot to the project"""
@@ -86,7 +90,7 @@ async def update_slot(
     project_id: UUID,
     project_id: UUID,
     slot_id: UUID,
     slot_id: UUID,
     new_slot: SlotUpdateRequest,
     new_slot: SlotUpdateRequest,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Update a slot from the project"""
     """Update a slot from the project"""
@@ -132,7 +136,7 @@ async def update_slot(
 async def delete_slot(
 async def delete_slot(
     project_id: UUID,
     project_id: UUID,
     slot_id: UUID,
     slot_id: UUID,
-    current_user: User = Depends(deps.get_current_user),
+    current_user: User = Depends(deps.require_org_role(*WRITE_ROLES)),
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Delete a slot from the project"""
     """Delete a slot from the project"""

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

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

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

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

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

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

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

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

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

@@ -5,7 +5,11 @@ from sqlalchemy.orm import Session, selectinload
 from app.api import deps
 from app.api import deps
 from app.core.security import get_password_hash
 from app.core.security import get_password_hash
 from app.models import GlobalRole, User, UserOrganization
 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
 from app.schemas.responses import UserResponse
 
 
 router = APIRouter()
 router = APIRouter()
@@ -21,9 +25,9 @@ async def list_users(
     when adding commission members or org roles, without leaking the full
     when adding commission members or org roles, without leaking the full
     cross-organization user directory."""
     cross-organization user directory."""
     if current_user.global_role == GlobalRole.SUPER_ADMIN:
     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]
     org_ids = [m.organization_id for m in current_user.organizations]
     if not org_ids:
     if not org_ids:
@@ -109,4 +113,4 @@ async def register_new_user(
     session.add(user)
     session.add(user)
     session.commit()
     session.commit()
     session.refresh(user)
     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
 import time
 
 
@@ -74,7 +74,7 @@ def generate_access_token_response(subject: str | int):
 def verify_password(plain_password: str, hashed_password: str) -> bool:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verifies plain and hashed password matches
     """Verifies plain and hashed password matches
 
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     """
     return PWD_CONTEXT.verify(plain_password, hashed_password)
     return PWD_CONTEXT.verify(plain_password, hashed_password)
@@ -83,7 +83,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
 def get_password_hash(password: str) -> str:
 def get_password_hash(password: str) -> str:
     """Creates hash from password
     """Creates hash from password
 
 
-    Applies passlib context based on bcrypt algorithm on plain passoword.
+    Applies passlib context based on bcrypt algorithm on plain password.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     """
     return PWD_CONTEXT.hash(password)
     return PWD_CONTEXT.hash(password)

+ 3 - 3
app/create_sms_batch.py

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

+ 4 - 3
app/initial_data.py

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

+ 38 - 16
app/models.py

@@ -13,8 +13,8 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 # apply all migrations
 alembic upgrade head
 alembic upgrade head
 """
 """
+
 import enum
 import enum
-from typing import Optional
 import uuid
 import uuid
 from datetime import datetime
 from datetime import datetime
 
 
@@ -39,6 +39,7 @@ class GlobalRole(str, enum.Enum):
     SUPER_ADMIN = "super_admin"
     SUPER_ADMIN = "super_admin"
     USER = "user"
     USER = "user"
 
 
+
 class OrgRole(str, enum.Enum):
 class OrgRole(str, enum.Enum):
     ORG_ADMIN = "org_admin"
     ORG_ADMIN = "org_admin"
     RESPO_BENEVOLE = "respo_benevole"
     RESPO_BENEVOLE = "respo_benevole"
@@ -51,7 +52,7 @@ class User(Base):
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
     name: Mapped[str] = mapped_column(String(128), default="")
     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(
     global_role: Mapped[GlobalRole] = mapped_column(
         SAEnum(GlobalRole, name="global_role"), default=GlobalRole.USER, nullable=False
         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)
     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(
     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
         passive_deletes=True,  # let the DB's ON DELETE CASCADE do the actual delete
     )
     )
 
 
 
 
 class UserOrganization(Base):
 class UserOrganization(Base):
     """Many-to-many: a user can belong to several orgs, one role per org."""
     """Many-to-many: a user can belong to several orgs, one role per org."""
+
     __tablename__ = "user_organizations"
     __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(
     organization_id: Mapped[UUID] = mapped_column(
         ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
         ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
     )
     )
@@ -96,7 +103,7 @@ class UserOrganization(Base):
 class Project(Base):
 class Project(Base):
     __tablename__ = "projects"
     __tablename__ = "projects"
     id: Mapped[UUID] = uid_column()
     id: Mapped[UUID] = uid_column()
-    organization_id: Mapped[str] = mapped_column(
+    organization_id: Mapped[UUID] = mapped_column(
         ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
         ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
     )
     )
     organization: Mapped["Organization"] = relationship(back_populates="projects")
     organization: Mapped["Organization"] = relationship(back_populates="projects")
@@ -121,11 +128,15 @@ class Project(Base):
     tags: Mapped[list["SlotTag"]] = relationship(
     tags: Mapped[list["SlotTag"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
         back_populates="project", cascade="delete, delete-orphan"
     )
     )
-    commissions: Mapped[list["Commission"]] = relationship(back_populates="project", cascade="delete, delete-orphan")
+    commissions: Mapped[list["Commission"]] = relationship(
+        back_populates="project", cascade="delete, delete-orphan"
+    )
 
 
     groups: Mapped[list["VolunteerGroup"]] = relationship(
     groups: Mapped[list["VolunteerGroup"]] = relationship(
         back_populates="project", cascade="all, delete-orphan", passive_deletes=True
         back_populates="project", cascade="all, delete-orphan", passive_deletes=True
     )
     )
+
+
 class Commission(Base):
 class Commission(Base):
     __tablename__ = "commissions"
     __tablename__ = "commissions"
     id: Mapped[UUID] = uid_column()
     id: Mapped[UUID] = uid_column()
@@ -146,12 +157,17 @@ class Commission(Base):
 
 
 class CommissionMember(Base):
 class CommissionMember(Base):
     __tablename__ = "commission_members"
     __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")
     commission: Mapped["Commission"] = relationship(back_populates="members")
     user: Mapped["User"] = relationship(back_populates="commissions")
     user: Mapped["User"] = relationship(back_populates="commissions")
 
 
+
 association_table_volunteer_slot = Table(
 association_table_volunteer_slot = Table(
     "association_volunteer_slot",
     "association_volunteer_slot",
     Base.metadata,
     Base.metadata,
@@ -170,6 +186,7 @@ association_table_volunteer_group = Table(
     Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
     Column("group_id", ForeignKey("volunteer_groups.id", ondelete="CASCADE"), primary_key=True),
 )
 )
 
 
+
 class VolunteerGroup(Base):
 class VolunteerGroup(Base):
     __tablename__ = "volunteer_groups"
     __tablename__ = "volunteer_groups"
     id: Mapped[UUID] = uid_column()
     id: Mapped[UUID] = uid_column()
@@ -180,15 +197,17 @@ class VolunteerGroup(Base):
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
     )
     name: Mapped[str] = mapped_column(String(128), nullable=False)
     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(
     volunteers: Mapped[list["Volunteer"]] = relationship(
         secondary=association_table_volunteer_group, back_populates="groups"
         secondary=association_table_volunteer_group, back_populates="groups"
     )
     )
+
     @hybrid_property
     @hybrid_property
     def volunteers_id(self) -> list[str]:
     def volunteers_id(self) -> list[str]:
         return [v.id for v in self.volunteers]
         return [v.id for v in self.volunteers]
 
 
+
 class Volunteer(Base):
 class Volunteer(Base):
     __tablename__ = "volunteers"
     __tablename__ = "volunteers"
     id: Mapped[UUID] = uid_column()
     id: Mapped[UUID] = uid_column()
@@ -240,7 +259,7 @@ class Slot(Base):
         secondary=association_table_volunteer_slot, back_populates="slots"
         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
         ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
     )
     )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
@@ -298,10 +317,12 @@ class SlotTemplate(Base):
     title: Mapped[str] = mapped_column(String(), default="")
     title: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     description: Mapped[str] = mapped_column(String(), default="")
     place: Mapped[str] = mapped_column(String(), default="")
     place: Mapped[str] = mapped_column(String(), default="")
-    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")
     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")
     slots: Mapped[list[Slot]] = relationship(back_populates="template")
 
 
     tags: Mapped[list[SlotTag]] = relationship(
     tags: Mapped[list[SlotTag]] = relationship(
@@ -359,7 +380,8 @@ class ServerStatus(Base):
     __tablename__ = "server_status"
     __tablename__ = "server_status"
     # Use a fixed ID to ensure we only ever have one row
     # Use a fixed ID to ensure we only ever have one row
     id = Column(Integer, primary_key=True, default=1)
     id = Column(Integer, primary_key=True, default=1)
-    updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now())
+    updated_at = Column(
+        DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=func.now()
+    )
     host = Column(String, nullable=False)
     host = Column(String, nullable=False)
     user_agent = Column(String)
     user_agent = Column(String)
-

+ 38 - 8
app/tests/conftest.py

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

+ 50 - 7
app/tests/test_project.py

@@ -39,13 +39,14 @@ class TestListProject:
         assert len(response.json()) == 1
         assert len(response.json()) == 1
 
 
     async def test_excludes_other_orgs_project(
     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)
         response = await client.get(app.url_path_for("list_project"), headers=headers)
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json() == []
         assert response.json() == []
@@ -87,6 +88,16 @@ class TestGetProject:
         )
         )
         assert response.status_code == status
         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:
 class TestGetPublicProject:
     async def test_missing_or_private_returns_404(
     async def test_missing_or_private_returns_404(
@@ -188,6 +199,17 @@ class TestCreateProject:
         )
         )
         assert response.status_code == 400
         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:
 class TestUpdateProject:
     @pytest.mark.parametrize(
     @pytest.mark.parametrize(
@@ -221,7 +243,7 @@ class TestUpdateProject:
             assert project.name == "Coucou"
             assert project.name == "Coucou"
 
 
     async def test_requires_auth(
     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(
         response = await client.post(
             app.url_path_for("update_project", project_id=default_project_id),
             app.url_path_for("update_project", project_id=default_project_id),
@@ -230,6 +252,17 @@ class TestUpdateProject:
         assert response.status_code == 401
         assert response.status_code == 401
         assert session.get(Project, default_project_id).name == default_project_name
         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(
     async def test_validation_error(
         self, client: AsyncClient, default_public_project: Project, make_org_user
         self, client: AsyncClient, default_public_project: Project, make_org_user
     ):
     ):
@@ -297,6 +330,16 @@ class TestDeleteProject:
         )
         )
         assert response.status_code == 401
         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(
     async def test_cascades_to_volunteers_and_slots(
         self, client: AsyncClient, default_public_project: Project, make_org_user, session: Session
         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 sqlalchemy.orm import Session
 
 
 from app.main import app
 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 (
 from app.tests.conftest import (
     default_project_id,
     default_project_id,
     default_slot_id,
     default_slot_id,
@@ -121,6 +129,22 @@ class TestListProjectSlots:
         assert response.status_code == 200
         assert response.status_code == 200
         assert len(response.json()) == 1
         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:
 class TestCreateSlot:
     async def test_requires_auth(self, client: AsyncClient):
     async def test_requires_auth(self, client: AsyncClient):
@@ -273,6 +297,28 @@ class TestCreateSlot:
         )
         )
         assert response.status_code == 403
         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:
 class TestUpdateSlot:
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
@@ -531,6 +577,32 @@ class TestUpdateSlot:
         )
         )
         assert response.status_code == 403
         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:
 class TestDeleteSlot:
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
     async def test_requires_auth(self, client: AsyncClient, default_public_project: Project):
@@ -539,6 +611,20 @@ class TestDeleteSlot:
         )
         )
         assert response.status_code == 401
         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(
     async def test_invalid_slot_id(
         self,
         self,
         client: AsyncClient,
         client: AsyncClient,

+ 359 - 252
app/tests/test_sms.py

@@ -1,263 +1,370 @@
-from datetime import datetime, timezone
 import uuid
 import uuid
+from datetime import UTC, datetime
+
+import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 from app.main import app
-from app.models import Project, Sms, Volunteer
-from app.tests.conftest import default_project_id, default_volunteer_id, default_sms_id
-
-
-async def test_read_list_project_sms(
-    client: AsyncClient, default_user_headers: dict, default_public_project: Project
-):
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=default_project_id),
-    )
-    assert response.status_code == 401
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=uuid.uuid4()),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id="pas un uuid valid"),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-    response = await client.get(
-        app.url_path_for("list_project_sms", project_id=default_project_id),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    data = response.json()
-    assert len(data) == 1
-    assert data[0]["id"] == default_sms_id
-
-
-async def test_create_sms(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(app.url_path_for("create_sms", project_id=default_project_id))
-    assert response.status_code == 401
-    payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
-    # test invalid project_id
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=uuid.uuid4()),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 404
-
-    # Test normal payload
-    before_creation_time = datetime.now(timezone.utc)
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-    assert response.json()["content"] == "sms_content"
-    result = session.execute(
-        select(Sms).where((Sms.project_id == default_project_id) & (Sms.id != default_sms_id))
-    )
-    sms = result.scalars().first()
-
-    assert sms is not None
-    assert sms.content == "sms_content"
-    assert before_creation_time < sms.sending_time
-    assert sms.sending_time < datetime.now(timezone.utc)
-
-    # test invalid payload
-    del payload["content"]
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=default_project_id),
-        json=payload,
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
-
-
-async def test_update_sms(
-    client: AsyncClient,
-    default_public_project: Project,
-    default_user_headers: dict,
-    session: Session,
-):
-    # Test without autentication
-    response = await client.post(
-        app.url_path_for(
-            "update_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
+from app.models import OrgRole, Project, Sms, Volunteer
+from app.tests.conftest import default_project_id, default_sms_id, default_volunteer_id
+
+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(
         response = await client.post(
-            app.url_path_for(
-                "update_sms",
-                project_id=default_project_id,
-                sms_id=default_sms_id,
-            ),
-            json={k: v},
-            headers=default_user_headers,
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
-        assert response.json()["id"] == default_sms_id
-        if "time" in k:
-            parsed_time = datetime.fromisoformat(response.json()[k])
-            assert parsed_time == datetime.fromisoformat(v)
-        else:
-            assert response.json()[k] == v
-
-
-async def test_delete_sms(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    # Fail deleting the project due to not logged in
-    response = await client.delete(
-        app.url_path_for(
-            "delete_sms",
-            project_id=default_project_id,
-            sms_id=default_sms_id,
+        assert response.json()["content"] == "sms_content"
+
+        sms = (
+            session.execute(
+                select(Sms).where(
+                    (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
+                )
+            )
+            .scalars()
+            .first()
+        )
+        assert sms is not None
+        assert sms.content == "sms_content"
+        assert before_creation_time < sms.sending_time
+        assert sms.sending_time < datetime.now(UTC)
+
+    async def test_no_membership_forbidden(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=None)
+        payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
+        response = await client.post(
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 403
+
+    async def test_invalid_payload(
+        self, client: AsyncClient, default_public_project: Project, make_org_user
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {"phone_number": "06 75 75 75 75 "}  # missing content
+        response = await client.post(
+            app.url_path_for("create_sms", project_id=default_project_id),
+            json=payload,
+            headers=headers,
+        )
+        assert response.status_code == 422
+
+    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 datetime import datetime, timedelta
-from httpx import AsyncClient
+
 import pytest
 import pytest
+from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -8,479 +9,549 @@ from app.main import app
 from app.models import (
 from app.models import (
     Commission,
     Commission,
     CommissionMember,
     CommissionMember,
+    OrgRole,
     Project,
     Project,
     Slot,
     Slot,
     SlotTemplate,
     SlotTemplate,
     Sms,
     Sms,
     User,
     User,
     Volunteer,
     Volunteer,
-    OrgRole,
 )
 )
 from app.tests.conftest import default_sms_id, default_volunteer_id
 from app.tests.conftest import default_sms_id, default_volunteer_id
 
 
+pytestmark = pytest.mark.asyncio
+
+WRITE_FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
+
 
 
 def get_arthur(session) -> Volunteer:
 def get_arthur(session) -> Volunteer:
     result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
     result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
     arthur = result.scalars().first()
     arthur = result.scalars().first()
     assert arthur is not None
     assert arthur is not None
     return arthur
     return arthur
+
+
 @pytest.fixture()
 @pytest.fixture()
 def user_headers(make_org_user) -> dict:
 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
     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,
             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)
 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
 import uuid
-from httpx import AsyncClient
+from datetime import datetime
+
 import pytest
 import pytest
+from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 from app.main import app
-from app.models import Project, Slot, 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
         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(
     async def test_validation_error(
         self,
         self,
         client: AsyncClient,
         client: AsyncClient,
@@ -146,15 +157,13 @@ class TestCreateTemplate:
         )
         )
         assert response.status_code == 403
         assert response.status_code == 403
 
 
-    @pytest.mark.parametrize("role", (None, OrgRole.RESPO_COMMISSION))
     async def test_respo_ok(
     async def test_respo_ok(
         self,
         self,
         client: AsyncClient,
         client: AsyncClient,
         default_public_project: Project,
         default_public_project: Project,
         make_org_user,
         make_org_user,
-        role,
     ):
     ):
-        _, headers = make_org_user(role=role)
+        _, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
         response = await client.post(
         response = await client.post(
             app.url_path_for("create_template", project_id=default_public_project.id),
             app.url_path_for("create_template", project_id=default_public_project.id),
             json={"title": "No commission"},
             json={"title": "No commission"},
@@ -175,6 +184,21 @@ class TestUpdateTemplate:
         )
         )
         assert response.status_code == 401
         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(
     async def test_invalid_template_id_format(
         self,
         self,
         client: AsyncClient,
         client: AsyncClient,
@@ -399,6 +423,19 @@ class TestDeleteTemplate:
         )
         )
         assert response.status_code == 401
         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(
     async def test_invalid_ids(
         self,
         self,
         client: AsyncClient,
         client: AsyncClient,

+ 31 - 20
app/tests/test_users.py

@@ -6,19 +6,18 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
 from app.main import app
 from app.main import app
-from app.models import 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
 pytestmark = pytest.mark.asyncio
 
 
+
 class TestListUsers:
 class TestListUsers:
     async def test_requires_auth(self, client: AsyncClient):
     async def test_requires_auth(self, client: AsyncClient):
         response = await client.get(app.url_path_for("list_users"))
         response = await client.get(app.url_path_for("list_users"))
         assert response.status_code == 401
         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)
         viewer, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         colleague, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)  # same default_organization
         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")
         other_org = Organization(id=uuid4(), name="Other Org")
         session.add(other_org)
         session.add(other_org)
         session.commit()
         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.add(outsider)
         session.commit()
         session.commit()
         session.add(
         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()
         session.commit()
 
 
@@ -74,7 +73,9 @@ class TestListUsers:
         session.add(outsider)
         session.add(outsider)
         session.commit()
         session.commit()
         session.add(
         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()
         session.commit()
 
 
@@ -92,13 +93,13 @@ class TestReadCurrentUser:
         assert response.status_code == 401
         assert response.status_code == 401
 
 
     async def test_returns_caller(self, client: AsyncClient, default_user_headers: dict):
     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.status_code == 200
         assert response.json()["email"] == default_user_email
         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)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.get(app.url_path_for("read_current_user"), headers=headers)
         response = await client.get(app.url_path_for("read_current_user"), headers=headers)
         assert response.status_code == 200
         assert response.status_code == 200
@@ -109,10 +110,14 @@ class TestReadCurrentUser:
 
 
 class TestUpdateCurrentUserProfile:
 class TestUpdateCurrentUserProfile:
     async def test_requires_auth(self, client: AsyncClient):
     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
         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(
         response = await client.patch(
             app.url_path_for("update_current_user_profile"),
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             headers=default_user_headers,
@@ -159,7 +164,9 @@ class TestDeleteCurrentUser:
         assert response.status_code == 204
         assert response.status_code == 204
         assert session.get(User, user.id) is None
         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)."""
         """Deleting a user should also remove their UserOrganization rows (FK cascade)."""
         from app.models import UserOrganization
         from app.models import UserOrganization
 
 
@@ -168,7 +175,9 @@ class TestDeleteCurrentUser:
 
 
         await client.delete(app.url_path_for("delete_current_user"), headers=headers)
         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
         assert result.scalars().first() is None
 
 
 
 
@@ -179,7 +188,9 @@ class TestResetPassword:
         )
         )
         assert response.status_code == 401
         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(
         response = await client.post(
             app.url_path_for("reset_current_user_password"),
             app.url_path_for("reset_current_user_password"),
             headers=default_user_headers,
             headers=default_user_headers,
@@ -232,4 +243,4 @@ class TestRegisterUser:
             app.url_path_for("register_new_user"),
             app.url_path_for("register_new_user"),
             json={"email": "no-name@test.com", "password": "hunter2"},
             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 import select
 from sqlalchemy.orm import Session
 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.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
 pytestmark = pytest.mark.asyncio
 
 
@@ -20,12 +20,20 @@ FORBIDDEN_ROLES = [OrgRole.RESPO_COMMISSION, None]
 def two_volunteers(default_project: Project) -> tuple[Volunteer, Volunteer]:
 def two_volunteers(default_project: Project) -> tuple[Volunteer, Volunteer]:
     with session_maker() as db:
     with session_maker() as db:
         v1 = Volunteer(
         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(
         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.add_all([v1, v2])
         db.commit()
         db.commit()
@@ -47,7 +55,12 @@ def default_group(default_project: Project) -> VolunteerGroup:
 class TestListGroups:
 class TestListGroups:
     @pytest.mark.parametrize("role", MANAGE_ROLES)
     @pytest.mark.parametrize("role", MANAGE_ROLES)
     async def test_role_access(
     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)
         _, headers = make_org_user(role=role)
         response = await client.get(
         response = await client.get(
@@ -59,7 +72,9 @@ class TestListGroups:
         assert data[0]["name"] == "Chevaliers"
         assert data[0]["name"] == "Chevaliers"
 
 
     @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
     @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)
         _, headers = make_org_user(role=role)
         response = await client.get(
         response = await client.get(
             app.url_path_for("list_project_groups", project_id=default_project.id), headers=headers
             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
         assert response.status_code == 403
 
 
     async def test_project_not_found(self, client: AsyncClient, make_org_user):
     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
         # super_admin needed since require_org_role 404s on missing project regardless of role
         from app.models import GlobalRole
         from app.models import GlobalRole
+
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         response = await client.get(
         response = await client.get(
             app.url_path_for("list_project_groups", project_id=uuid.uuid4()), headers=headers
             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"},
             json={"name": "Bar team", "color": "#00FF00"},
         )
         )
         assert response.status_code == 200
         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 is not None
         assert group.color == "#00FF00"
         assert group.color == "#00FF00"
 
 
     @pytest.mark.parametrize("role", FORBIDDEN_ROLES)
     @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)
         _, headers = make_org_user(role=role)
         response = await client.post(
         response = await client.post(
             app.url_path_for("create_group", project_id=default_project.id),
             app.url_path_for("create_group", project_id=default_project.id),
@@ -140,11 +164,18 @@ class TestGetGroup:
 
 
 class TestUpdateGroup:
 class TestUpdateGroup:
     async def test_updates_name_and_color(
     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)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.patch(
         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,
             headers=headers,
             json={"name": "Renamed", "color": "#0000FF"},
             json={"name": "Renamed", "color": "#0000FF"},
         )
         )
@@ -156,8 +187,13 @@ class TestUpdateGroup:
 
 
 class TestDeleteGroup:
 class TestDeleteGroup:
     async def test_deletes_group_without_deleting_volunteers(
     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
         v1, _ = two_volunteers
         group = session.get(VolunteerGroup, default_group.id)
         group = session.get(VolunteerGroup, default_group.id)
@@ -167,7 +203,9 @@ class TestDeleteGroup:
 
 
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         response = await client.delete(
         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,
             headers=headers,
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
@@ -177,13 +215,19 @@ class TestDeleteGroup:
 
 
 class TestGroupMembership:
 class TestGroupMembership:
     async def test_add_volunteers_to_group(
     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
         v1, v2 = two_volunteers
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.post(
         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,
             headers=headers,
             json={"volunteer_ids": [v1.id, v2.id]},
             json={"volunteer_ids": [v1.id, v2.id]},
         )
         )
@@ -191,18 +235,26 @@ class TestGroupMembership:
         assert sorted(response.json()["volunteers_id"]) == sorted([v1.id, v2.id])
         assert sorted(response.json()["volunteers_id"]) == sorted([v1.id, v2.id])
 
 
     async def test_add_is_idempotent(
     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
         v1, _ = two_volunteers
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         await client.post(
         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,
             headers=headers,
             json={"volunteer_ids": [v1.id]},
             json={"volunteer_ids": [v1.id]},
         )
         )
         response = await client.post(
         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,
             headers=headers,
             json={"volunteer_ids": [v1.id]},
             json={"volunteer_ids": [v1.id]},
         )
         )
@@ -210,17 +262,26 @@ class TestGroupMembership:
         assert response.json()["volunteers_id"] == [v1.id]
         assert response.json()["volunteers_id"] == [v1.id]
 
 
     async def test_volunteer_from_other_project_rejected(
     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")
         other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
         session.add(other_org)
         session.add(other_org)
         session.commit()
         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.add(other_project)
         session.commit()
         session.commit()
         stray_volunteer = Volunteer(
         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",
             phone_number="0600000000",
         )
         )
         session.add(stray_volunteer)
         session.add(stray_volunteer)
@@ -228,15 +289,22 @@ class TestGroupMembership:
 
 
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.post(
         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,
             headers=headers,
             json={"volunteer_ids": [stray_volunteer.id]},
             json={"volunteer_ids": [stray_volunteer.id]},
         )
         )
         assert response.status_code == 400
         assert response.status_code == 400
 
 
     async def test_remove_volunteer_from_group(
     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
         v1, v2 = two_volunteers
         group = session.get(VolunteerGroup, default_group.id)
         group = session.get(VolunteerGroup, default_group.id)
@@ -248,7 +316,9 @@ class TestGroupMembership:
         response = await client.delete(
         response = await client.delete(
             app.url_path_for(
             app.url_path_for(
                 "remove_volunteer_from_group",
                 "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,
             headers=headers,
         )
         )
@@ -258,8 +328,13 @@ class TestGroupMembership:
 
 
 class TestAddGroupToSlot:
 class TestAddGroupToSlot:
     async def test_bulk_assigns_all_group_members(
     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
         v1, v2 = two_volunteers
         group = session.get(VolunteerGroup, default_group.id)
         group = session.get(VolunteerGroup, default_group.id)
@@ -277,7 +352,10 @@ class TestAddGroupToSlot:
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         response = await client.post(
         response = await client.post(
             app.url_path_for(
             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,
             headers=headers,
         )
         )
@@ -286,18 +364,26 @@ class TestAddGroupToSlot:
         assert sorted(ids) == sorted([v1.id, v2.id])
         assert sorted(ids) == sorted([v1.id, v2.id])
 
 
     async def test_slot_from_other_project_not_found(
     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")
         other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
         session.add(other_org)
         session.add(other_org)
         session.commit()
         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.add(other_project)
         session.commit()
         session.commit()
         stray_slot = Slot(
         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.add(stray_slot)
         session.commit()
         session.commit()
@@ -306,7 +392,9 @@ class TestAddGroupToSlot:
         response = await client.post(
         response = await client.post(
             app.url_path_for(
             app.url_path_for(
                 "add_group_to_slot",
                 "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,
             headers=headers,
         )
         )
@@ -315,8 +403,13 @@ class TestAddGroupToSlot:
 
 
 class TestSendSmsToGroup:
 class TestSendSmsToGroup:
     async def test_sends_to_each_member_with_automatic_sms(
     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
         v1, v2 = two_volunteers
         group = session.get(VolunteerGroup, default_group.id)
         group = session.get(VolunteerGroup, default_group.id)
@@ -326,7 +419,9 @@ class TestSendSmsToGroup:
 
 
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         response = await client.post(
         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,
             headers=headers,
             json={"content": "Bonjour {prenom} {nom} !"},
             json={"content": "Bonjour {prenom} {nom} !"},
         )
         )
@@ -335,8 +430,13 @@ class TestSendSmsToGroup:
         assert contents == ["Bonjour Lancelot Lac !", "Bonjour Yvain Lion !"]
         assert contents == ["Bonjour Lancelot Lac !", "Bonjour Yvain Lion !"]
 
 
     async def test_skips_volunteers_without_automatic_sms(
     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
         v1, v2 = two_volunteers
         vol2 = session.get(Volunteer, v2.id)
         vol2 = session.get(Volunteer, v2.id)
@@ -348,10 +448,12 @@ class TestSendSmsToGroup:
 
 
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         response = await client.post(
         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,
             headers=headers,
             json={"content": "Bonjour {prenom} !"},
             json={"content": "Bonjour {prenom} !"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         assert len(response.json()) == 1
         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]]
 [[package]]
 name = "bcrypt"
 name = "bcrypt"
-version = "4.3.0"
+version = "4.0.1"
 description = "Modern password hashing for your software and your servers"
 description = "Modern password hashing for your software and your servers"
 optional = false
 optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.6"
 groups = ["main"]
 groups = ["main"]
 files = [
 files = [
-    {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"},
-    {file = "bcrypt-4.3.0-cp313-cp313t-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]
 [package.extras]
@@ -2252,4 +2222,4 @@ files = [
 [metadata]
 [metadata]
 lock-version = "2.1"
 lock-version = "2.1"
 python-versions = "^3.11"
 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"
 pydantic-settings = "^2.2.1"
 psycopg2 = "^2.9.9"
 psycopg2 = "^2.9.9"
 ruff = "^0.16.0"
 ruff = "^0.16.0"
+bcrypt = "4.0.1"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"
 coverage = "^7.1.0"