Przeglądaj źródła

test: implement project constraints

clovis 1 tydzień temu
rodzic
commit
48990d9594

+ 8 - 0
.env.example

@@ -1,3 +1,4 @@
+FRONT_END_URL="http://localhost:8000"
 SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG
 ENVIRONMENT=DEV
 ACCESS_TOKEN_EXPIRE_MINUTES=11520
@@ -22,3 +23,10 @@ BATCH_SMS_PHONE_NUMBER="01 10 10 10 10"
 
 FIRST_SUPERUSER_EMAIL=example@example.com
 FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC
+
+SMTP_HOST=""
+SMTP_PORT=587
+SMTP_USERNAME=""
+SMTP_PASSWORD=""
+EMAIL_FROM_ADDRESS="no-reply@example.com"
+EMAIL_FROM_NAME="BDLG Planner"

+ 32 - 0
alembic/versions/2026072635_add_must_change_password_to_user_3b96bd12a533.py

@@ -0,0 +1,32 @@
+"""add must_change_password to user
+
+Revision ID: 3b96bd12a533
+Revises: b9072f955a8d
+Create Date: 2026-07-26 18:35:09.831937
+
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "3b96bd12a533"
+down_revision = "b9072f955a8d"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "user_model",
+        sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default="false"),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("user_model", "must_change_password")
+    # ### end Alembic commands ###

+ 51 - 0
alembic/versions/2026072652_add_password_reset_request_log_a2c893432a1f.py

@@ -0,0 +1,51 @@
+"""add password_reset_request_log
+
+Revision ID: a2c893432a1f
+Revises: 3b96bd12a533
+Create Date: 2026-07-26 19:52:07.317867
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a2c893432a1f'
+down_revision = '3b96bd12a533'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('password_reset_request_log',
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('email', sa.String(length=254), nullable=False),
+    sa.Column('requested_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_password_reset_request_log_email'), 'password_reset_request_log', ['email'], unique=False)
+    op.create_table('password_reset_tokens',
+    sa.Column('id', sa.UUID(as_uuid=False), nullable=False),
+    sa.Column('user_id', sa.UUID(as_uuid=False), nullable=False),
+    sa.Column('token_hash', sa.String(length=64), nullable=False),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+    sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+    sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
+    sa.Column('purpose', sa.String(length=32), nullable=False),
+    sa.ForeignKeyConstraint(['user_id'], ['user_model.id'], ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_password_reset_tokens_token_hash'), 'password_reset_tokens', ['token_hash'], unique=True)
+    op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens')
+    op.drop_index(op.f('ix_password_reset_tokens_token_hash'), table_name='password_reset_tokens')
+    op.drop_table('password_reset_tokens')
+    op.drop_index(op.f('ix_password_reset_request_log_email'), table_name='password_reset_request_log')
+    op.drop_table('password_reset_request_log')
+    # ### end Alembic commands ###

+ 52 - 0
alembic/versions/2026072729_persist_project_constraints_f647c6bd94ae.py

@@ -0,0 +1,52 @@
+"""persist project_constraints
+
+Revision ID: f647c6bd94ae
+Revises: a2c893432a1f
+Create Date: 2026-07-27 01:29:36.369652
+
+"""
+
+import json
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "f647c6bd94ae"
+down_revision = "a2c893432a1f"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    default_constraints = json.dumps(
+        {
+            "dejeuner_start": 11,
+            "dejeuner_end": 14,
+            "diner_start": 19,
+            "diner_end": 22,
+            "nuit_start": 22,
+            "nuit_end": 10,
+            "duree_repas_h": 1.0,
+            "duree_nuit_h": 8.0,
+            "duree_pause_h": 0.5,
+        }
+    )
+    op.add_column(
+        "projects",
+        sa.Column(
+            "constraints",
+            sa.JSON(),
+            nullable=False,
+            server_default=default_constraints,
+        ),
+    )
+    # drop the server_default after backfill so future inserts must go through
+    # the Pydantic model (PlanningConstraints) rather than silently relying on
+    # a DB-level default that can drift from the schema's defaults over time
+    op.alter_column("projects", "constraints", server_default=None)
+
+
+def downgrade():
+    op.drop_column("projects", "constraints")

+ 8 - 2
app/api/deps.py

@@ -12,12 +12,12 @@ from app.api.utils import get_project_organization_id
 from app.core import config, security
 from app.core.session import session
 from app.models import (
-    CommissionMember,
     GlobalRole,
     OrgRole,
     Project,
     User,
     UserOrganization,
+    association_table_commission_member,
 )
 
 reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
@@ -93,7 +93,13 @@ def _has_org_role(
 def _is_commission_member(session: Session, user_id: str, commission_id: str | None) -> bool:
     if commission_id is None:
         return False
-    return session.get(CommissionMember, (commission_id, user_id)) is not None
+    stmt = select(
+        exists().where(
+            association_table_commission_member.c.commission_id == commission_id,
+            association_table_commission_member.c.user_id == user_id,
+        )
+    )
+    return session.scalar(stmt)
 
 
 def require_org_role(*allowed_roles: OrgRole):

+ 82 - 18
app/api/endpoints/commissions.py

@@ -1,16 +1,21 @@
+import secrets
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
-from app.api.utils import assert_project_exists_or_404
-from app.models import Commission, CommissionMember, OrgRole, User
+from app.api.utils import assert_project_exists_or_404, issue_reset_token
+from app.core.config import settings
+from app.core.email import EmailSender, get_email_sender
+from app.core.security import get_password_hash
+from app.models import Commission, OrgRole, Project, User, UserOrganization
 from app.schemas.requests import (
     CommissionCreateRequest,
     CommissionMembershipRequest,
     CommissionUpdateRequest,
+    UserInviteRequest,
 )
 from app.schemas.responses import CommissionResponse
 
@@ -21,8 +26,12 @@ MANAGE_COMMISSIONS = (OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)
 
 
 def _get_commission_or_404(session: Session, project_id: UUID, commission_id: UUID) -> Commission:
-    commission = session.get(Commission, commission_id)
-    if commission is None or commission.project_id != str(project_id):
+    commission = session.scalar(
+        select(Commission)
+        .where(Commission.id == commission_id)
+        .options(selectinload(Commission.members))
+    )
+    if commission is None or str(commission.project_id) != str(project_id):
         raise HTTPException(status_code=404, detail="Commission not found")
     return commission
 
@@ -66,7 +75,7 @@ async def get_commission(
     return _get_commission_or_404(session, project_id, commission_id)
 
 
-@router.patch("/commission/{commission_id}", response_model=CommissionResponse)
+@router.post("/commission/{commission_id}", response_model=CommissionResponse)
 async def update_commission(
     project_id: UUID,
     commission_id: UUID,
@@ -111,17 +120,13 @@ async def add_members_to_commission(
     if len(valid_users) != len(payload.user_ids):
         raise HTTPException(status_code=400, detail="One or more invalid user IDs")
 
-    existing_user_ids = {str(m.user_id) for m in commission.members}
-
+    existing_ids = {u.id for u in commission.members}
     for user in valid_users:
-        if str(user.id) in existing_user_ids:
-            continue
-        new_member = CommissionMember(commission_id=commission.id, user_id=user.id)
-        commission.members.append(new_member)
-
+        if user.id not in existing_ids:
+            commission.members.append(user)
     session.commit()
-    session.refresh(commission)
-    return commission
+
+    return _get_commission_or_404(session, project_id, commission_id)
 
 
 @router.delete("/commission/{commission_id}/member/{user_id}", response_model=CommissionResponse)
@@ -135,11 +140,70 @@ async def remove_member_from_commission(
     """Remove a single user from a commission"""
     commission = _get_commission_or_404(session, project_id, commission_id)
 
-    member_to_remove = next((m for m in commission.members if str(m.user_id) == str(user_id)), None)
+    member_to_remove = next((m for m in commission.members if str(m.id) == str(user_id)), None)
 
     if member_to_remove:
         session.delete(member_to_remove)
         session.commit()
-        session.refresh(commission)
 
-    return commission
+    return _get_commission_or_404(session, project_id, commission_id)
+
+
+@router.post(
+    "/commission/{commission_id}/invite-member",
+    response_model=CommissionResponse,
+)
+async def invite_commission_member(
+    project_id: UUID,
+    commission_id: UUID,
+    payload: UserInviteRequest,
+    current_user: User = Depends(deps.require_org_role(*MANAGE_COMMISSIONS)),
+    session: Session = Depends(deps.get_session),
+    email_sender: EmailSender = Depends(get_email_sender),
+):
+    commission = _get_commission_or_404(session, project_id, commission_id)
+    project = session.get(Project, project_id)
+
+    user = session.scalar(select(User).where(User.email == payload.email))
+    is_new_user = user is None
+
+    if payload.org_role == OrgRole.ORG_ADMIN:
+        caller_membership = session.get(
+            UserOrganization, (current_user.id, project.organization_id)
+        )
+        is_caller_org_admin = (
+            caller_membership is not None and caller_membership.role == OrgRole.ORG_ADMIN
+        )
+        if not (is_caller_org_admin or current_user.global_role == "super_admin"):
+            raise HTTPException(status_code=403, detail="Only an org_admin can grant org_admin")
+
+    if is_new_user:
+        user = User(
+            email=payload.email,
+            name=payload.name,
+            phone_number=payload.phone_number,
+            hashed_password=get_password_hash(secrets.token_urlsafe(32)),  # unusable placeholder
+            must_change_password=True,
+        )
+        session.add(user)
+        session.flush()
+
+    existing_membership = session.get(UserOrganization, (user.id, project.organization_id))
+    if existing_membership is None:
+        session.add(
+            UserOrganization(
+                user_id=user.id,
+                organization_id=project.organization_id,
+                role=payload.org_role,
+            )
+        )
+
+    if user not in commission.members:
+        commission.members.append(user)
+
+    session.commit()
+
+    if is_new_user:
+        await issue_reset_token(session, user, "invite", email_sender, settings.FRONT_END_URL)
+
+    return _get_commission_or_404(session, project_id, commission_id)

+ 2 - 5
app/api/endpoints/organizations.py

@@ -81,7 +81,7 @@ async def get_organization(
     return org
 
 
-@router.patch("/{organization_id}", response_model=OrganizationResponse)
+@router.post("/{organization_id}", response_model=OrganizationResponse)
 async def update_organization(
     organization_id: UUID,
     edit_org: OrganizationRequest,
@@ -117,9 +117,6 @@ async def delete_organization(
     session.commit()
 
 
-# ---- Membership management ----
-
-
 @router.post("/{organization_id}/members", response_model=OrganizationMembershipResponse)
 async def add_member(
     organization_id: UUID,
@@ -152,7 +149,7 @@ async def add_member(
     return membership
 
 
-@router.patch("/{organization_id}/members/{user_id}", response_model=OrganizationMembershipResponse)
+@router.post("/{organization_id}/members/{user_id}", response_model=OrganizationMembershipResponse)
 async def update_member_role(
     organization_id: UUID,
     user_id: UUID,

+ 38 - 4
app/api/endpoints/project.py

@@ -4,12 +4,13 @@ from uuid import UUID
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
 from app.api.utils import assert_project_exists_or_404, get_project_or_404
 from app.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
 from app.models import (
+    Commission,
     GlobalRole,
     OrgRole,
     Project,
@@ -19,8 +20,10 @@ from app.models import (
     Sms,
     User,
     Volunteer,
+    VolunteerGroup,
 )
 from app.schemas.requests import (
+    ProjectConstraintUpdateRequest,
     ProjectCreateRequest,
     ProjectImportGsheetRequest,
     ProjectSMSBatchRequest,
@@ -104,7 +107,22 @@ async def get_project(
     session: Session = Depends(deps.get_session),
 ):
     """Get a project"""
-    project = session.get(Project, project_id)
+    project = session.scalar(
+        select(Project)
+        .where(Project.id == project_id)
+        .options(
+            selectinload(Project.volunteers),
+            selectinload(Project.sms),
+            selectinload(Project.slots).selectinload(Slot.volunteers),
+            selectinload(Project.slots).selectinload(Slot.template),
+            selectinload(Project.tags).selectinload(SlotTag.templates),
+            selectinload(Project.templates).selectinload(SlotTemplate.tags),
+            selectinload(Project.templates).selectinload(SlotTemplate.commission),
+            selectinload(Project.groups).selectinload(VolunteerGroup.volunteers),
+            selectinload(Project.commissions).selectinload(Commission.templates),
+            selectinload(Project.commissions).selectinload(Commission.members),
+        )
+    )
     if project is None:
         raise HTTPException(status_code=404, detail="Project not found")
     return project
@@ -125,6 +143,22 @@ async def update_project(
     return p
 
 
+@router.post("/project/{project_id}/constraints", response_model=ProjectResponse)
+async def update_project_constraints(
+    project_id: UUID,
+    payload: ProjectConstraintUpdateRequest,
+    current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
+    session: Session = Depends(deps.get_session),
+):
+    project = session.get(Project, project_id)
+    if project is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    project.constraints = payload.model_dump()
+    session.commit()
+    session.refresh(project)
+    return project
+
+
 @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
 async def update_project_from_gsheet(
     project_id: UUID,
@@ -165,7 +199,7 @@ async def update_project_from_gsheet(
     tags_map = {tag.title: tag for tag in p.tags}
 
     for _, row in data.creneauData.iterrows():
-        # NOTE: responsible_contact stays free-text on gsheet import for now —
+        # NOTE: responsible_override stays free-text on gsheet import for now —
         # linking to a real Commission requires matching against commission
         # names/members, which the sheet doesn't carry. Revisit once the
         # gsheet format itself is updated to reference commissions.
@@ -174,7 +208,7 @@ async def update_project_from_gsheet(
             title=row.title,
             description=row.description,
             place=row.lieu,
-            responsible_contact=row.responsable,
+            responsible_override=row.responsable,
         )
 
         for s_tag in row.tags.split(","):

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

@@ -1,6 +1,8 @@
+from typing import Annotated
 from uuid import UUID
 
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.params import Query
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 
@@ -25,10 +27,15 @@ async def list_project_sms(
     project_id: UUID,
     current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
+    skip: Annotated[int, Query(ge=0)] = 0,
+    limit: Annotated[int, Query(ge=1, le=20000)] = 20000,
 ):
     """List sms from project"""
     assert_project_exists_or_404(session, project_id)
-    results = session.execute(select(Sms).where(Sms.project_id == project_id))
+    stmt = select(Sms).where(Sms.project_id == project_id)
+    stmt = stmt.order_by(Sms.created_at.desc()).offset(skip).limit(limit)
+
+    results = session.execute(stmt)
     return results.scalars().all()
 
 

+ 54 - 3
app/api/endpoints/users.py

@@ -1,11 +1,19 @@
+import hashlib
+from datetime import UTC, datetime
+
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session, selectinload
 
 from app.api import deps
-from app.core.security import get_password_hash
-from app.models import GlobalRole, User, UserOrganization
+from app.api.utils import issue_reset_token
+from app.core.config import settings
+from app.core.email import EmailSender, get_email_sender
+from app.core.security import get_password_hash, is_reset_rate_limited, log_reset_request
+from app.models import GlobalRole, PasswordResetToken, User, UserOrganization
 from app.schemas.requests import (
+    ForgotPasswordRequest,
+    SetPasswordWithTokenRequest,
     UserCreateRequest,
     UserProfileUpdateRequest,
     UserUpdatePasswordRequest,
@@ -51,7 +59,7 @@ async def read_current_user(
     return current_user
 
 
-@router.patch("/me", response_model=UserResponse)
+@router.post("/me", response_model=UserResponse)
 async def update_current_user_profile(
     payload: UserProfileUpdateRequest,
     current_user: User = Depends(deps.get_current_user),
@@ -114,3 +122,46 @@ async def register_new_user(
     session.commit()
     session.refresh(user)
     return user
+
+
+@router.post("/forgot-password", status_code=204)
+async def forgot_password(
+    payload: ForgotPasswordRequest,
+    session: Session = Depends(deps.get_session),
+    email_sender: EmailSender = Depends(get_email_sender),
+):
+    """Always returns 204 regardless of whether the email exists --
+    prevents account enumeration via response-timing/content differences."""
+    log_reset_request(session, payload.email)
+
+    if is_reset_rate_limited(session, payload.email):
+        return
+    user = session.scalar(select(User).where(User.email == payload.email))
+    if user is not None:
+        await issue_reset_token(session, user, "reset", email_sender, settings.FRONT_END_URL)
+
+
+@router.post("/set-password", response_model=UserResponse)
+async def set_password_with_token(
+    payload: SetPasswordWithTokenRequest,
+    session: Session = Depends(deps.get_session),
+):
+    """Consumes a reset or invite token, sets the password, marks it used."""
+    token_hash = hashlib.sha256(payload.token.encode()).hexdigest()
+    reset_token = session.scalar(
+        select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
+    )
+    if (
+        reset_token is None
+        or reset_token.used_at is not None
+        or reset_token.expires_at < datetime.now(UTC)
+    ):
+        raise HTTPException(status_code=400, detail="Invalid or expired link")
+
+    user = session.get(User, reset_token.user_id)
+    user.hashed_password = get_password_hash(payload.password)
+    user.must_change_password = False
+    reset_token.used_at = datetime.now(UTC)
+    session.commit()
+    session.refresh(user)
+    return user

+ 1 - 1
app/api/endpoints/volunteer_groups.py

@@ -69,7 +69,7 @@ async def get_group(
     return _get_group_or_404(session, project_id, group_id)
 
 
-@router.patch("/group/{group_id}", response_model=VolunteerGroupResponse)
+@router.post("/group/{group_id}", response_model=VolunteerGroupResponse)
 async def update_group(
     project_id: UUID,
     group_id: UUID,

+ 44 - 1
app/api/utils.py

@@ -1,3 +1,5 @@
+import logging
+from datetime import UTC, datetime
 from uuid import UUID
 
 from fastapi import HTTPException
@@ -5,7 +7,11 @@ from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.sql import func
 
-from app.models import Project
+from app.core.email import EmailDeliveryError, EmailSender
+from app.core.security import RESET_TOKEN_TTL, generate_reset_token
+from app.models import PasswordResetToken, Project, User
+
+logger = logging.getLogger(__name__)
 
 
 def assert_project_exists_or_404(session: Session, project_id: UUID) -> None:
@@ -63,3 +69,40 @@ def update_object_from_payload(obj, payload: dict):
     """Update the ORM model object from a pydantic payload dictionary"""
     for attr_name, value in payload.items():
         setattr(obj, attr_name, value)
+
+
+async def issue_reset_token(
+    session: Session, user: User, purpose: str, email_sender: EmailSender, base_url: str
+) -> None:
+    raw_token, token_hash = generate_reset_token()
+    session.add(
+        PasswordResetToken(
+            user_id=user.id,
+            token_hash=token_hash,
+            expires_at=datetime.now(UTC) + RESET_TOKEN_TTL[purpose],
+            purpose=purpose,
+        )
+    )
+    session.commit()
+
+    link = f"{base_url}/set-password?token={raw_token}"
+    if purpose == "invite":
+        subject, body = (
+            "Vous avez été invité·e",
+            f"Créez votre mot de passe : <a href='{link}'>{link}</a>",
+        )
+    else:
+        subject, body = (
+            "Réinitialisation du mot de passe",
+            f"Réinitialisez ici : <a href='{link}'>{link}</a>",
+        )
+    try:
+        await email_sender.send(user.email, subject, body)
+    except EmailDeliveryError as exc:
+        logger.error(
+            "Failed to send %s email to user_id=%s email=%s: %s",
+            purpose,
+            user.id,
+            user.email,
+            exc,
+        )

+ 13 - 2
app/core/config.py

@@ -37,7 +37,10 @@ def build_postgreuri(
 
 class Settings(BaseSettings):
     # CORE SETTINGS
+    FRONT_END_URL: str
     SECRET_KEY: str
+    RESET_REQUEST_LIMIT: int = 3
+    RESET_REQUEST_WINDOW_HOUR: int = 1
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     SECURITY_BCRYPT_ROUNDS: int = 12
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
@@ -45,13 +48,22 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
 
-    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS:int = 180
+    INACTIVITY_SMS_SENDER_THRESHOLD_SECONDS: int = 180
 
     # PROJECT NAME, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     DESCRIPTION: str = PYPROJECT_CONTENT["description"]
 
+    # Email account to send reset password
+    SMTP_HOST: str = ""
+    SMTP_PORT: int = 587
+    SMTP_USERNAME: str = ""
+    SMTP_PASSWORD: str = ""
+    SMTP_USE_TLS: bool = True
+    EMAIL_FROM_ADDRESS: str = "no-reply@example.com"
+    EMAIL_FROM_NAME: str = "BDLG Planner"
+
     # POSTGRESQL DEFAULT DATABASE
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_USER: str
@@ -98,5 +110,4 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
-
 settings: Settings = Settings()  # type: ignore

+ 63 - 0
app/core/email.py

@@ -0,0 +1,63 @@
+import logging
+from abc import ABC, abstractmethod
+from email.message import EmailMessage
+
+import aiosmtplib
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class EmailSender(ABC):
+    @abstractmethod
+    async def send(self, to: str, subject: str, html_body: str) -> None: ...
+
+
+class LoggingEmailSender(EmailSender):
+    """Dev/test fallback: logs instead of sending. Wire a real provider
+    (SES, Postmark, SMTP) behind this interface before going to prod."""
+
+    async def send(self, to: str, subject: str, html_body: str) -> None:
+        logger.info(f"[EMAIL to={to}] {subject}\n{html_body}")
+
+
+class SmtpEmailSender(EmailSender):
+    """Sends via SMTP. Works with any provider that exposes SMTP
+    credentials (SES, Postmark, Sendgrid, a plain mailbox, etc)."""
+
+    async def send(self, to: str, subject: str, html_body: str) -> None:
+        message = EmailMessage()
+        message["From"] = f"{settings.EMAIL_FROM_NAME} <{settings.EMAIL_FROM_ADDRESS}>"
+        message["To"] = to
+        message["Subject"] = subject
+        message.set_content("Ce message nécessite un client compatible HTML.")
+        message.add_alternative(html_body, subtype="html")
+
+        try:
+            await aiosmtplib.send(
+                message,
+                hostname=settings.SMTP_HOST,
+                port=settings.SMTP_PORT,
+                username=settings.SMTP_USERNAME or None,
+                password=settings.SMTP_PASSWORD or None,
+                start_tls=settings.SMTP_USE_TLS,
+                timeout=10,
+            )
+        except (aiosmtplib.SMTPException, OSError) as exc:
+            # Don't let a transient SMTP outage 500 the caller's whole
+            # request (e.g. invite-member already committed the DB write) --
+            # log loudly so it's visible in monitoring, and let the caller
+            # decide whether to surface a degraded-but-successful response.
+            logger.error("Failed to send email to %s: %s", to, exc)
+            raise EmailDeliveryError(str(exc)) from exc
+
+
+class EmailDeliveryError(Exception):
+    pass
+
+
+def get_email_sender() -> EmailSender:
+    if settings.ENVIRONMENT == "PYTEST" or not settings.SMTP_HOST:
+        return LoggingEmailSender()
+    return SmtpEmailSender()

+ 33 - 0
app/core/security.py

@@ -1,12 +1,18 @@
 """Black-box security shortcuts to generate JWT tokens and password hashing and verification."""
 
+import hashlib
+import secrets
 import time
+from datetime import UTC, datetime, timedelta
 
 import jwt
 from passlib.context import CryptContext
 from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
 
 from app.core import config
+from app.models import PasswordResetRequestLog
 from app.schemas.responses import AccessTokenResponse
 
 JWT_ALGORITHM = "HS256"
@@ -26,6 +32,15 @@ class JWTTokenPayload(BaseModel):
     expires_at: int
 
 
+RESET_TOKEN_TTL = {"reset": timedelta(hours=1), "invite": timedelta(days=7)}
+
+
+def generate_reset_token() -> tuple[str, str]:
+    """Returns (raw_token_for_email, sha256_hash_for_db)."""
+    raw = secrets.token_urlsafe(32)
+    return raw, hashlib.sha256(raw.encode()).hexdigest()
+
+
 def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool):
     """Creates jwt access or refresh token for user.
 
@@ -87,3 +102,21 @@ def get_password_hash(password: str) -> str:
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     return PWD_CONTEXT.hash(password)
+
+
+def is_reset_rate_limited(session: Session, email: str) -> bool:
+    window_start = datetime.now(UTC) - timedelta(hours=config.settings.RESET_REQUEST_WINDOW_HOUR)
+    count = session.scalar(
+        select(func.count())
+        .select_from(PasswordResetRequestLog)
+        .where(
+            PasswordResetRequestLog.email == email,
+            PasswordResetRequestLog.requested_at >= window_start,
+        )
+    )
+    return count >= config.settings.RESET_REQUEST_LIMIT
+
+
+def log_reset_request(session: Session, email: str) -> None:
+    session.add(PasswordResetRequestLog(email=email))
+    session.commit()

+ 47 - 21
app/models.py

@@ -18,7 +18,7 @@ import enum
 import uuid
 from datetime import datetime
 
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
+from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Table
 from sqlalchemy import Enum as SAEnum
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
@@ -46,11 +46,20 @@ class OrgRole(str, enum.Enum):
     RESPO_COMMISSION = "respo_commission"
 
 
+association_table_commission_member = Table(
+    "commission_members",
+    Base.metadata,
+    Column("commission_id", ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True),
+    Column("user_id", ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
 class User(Base):
     __tablename__ = "user_model"
     id: Mapped[UUID] = uid_column()
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
+    must_change_password: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
     name: Mapped[str] = mapped_column(String(128), default="")
     phone_number: Mapped[str | None] = mapped_column(String(24), nullable=True)
     global_role: Mapped[GlobalRole] = mapped_column(
@@ -60,8 +69,33 @@ class User(Base):
     organizations: Mapped[list["UserOrganization"]] = relationship(
         back_populates="user", cascade="all, delete-orphan"
     )
-    commissions: Mapped[list["CommissionMember"]] = relationship(
-        back_populates="user", cascade="all, delete-orphan"
+    commissions: Mapped[list["Commission"]] = relationship(
+        secondary=association_table_commission_member, back_populates="members"
+    )
+
+
+class PasswordResetToken(Base):
+    __tablename__ = "password_reset_tokens"
+    id: Mapped[UUID] = uid_column()
+    user_id: Mapped[UUID] = mapped_column(
+        ForeignKey("user_model.id", ondelete="CASCADE"), index=True
+    )
+    user: Mapped["User"] = relationship()
+    token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)  # sha256 hex
+    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+    used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+    purpose: Mapped[str] = mapped_column(String(32))
+
+
+class PasswordResetRequestLog(Base):
+    """Lightweight rate-limit ledger — not user-facing, no need for a UUID PK."""
+
+    __tablename__ = "password_reset_request_log"
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    email: Mapped[str] = mapped_column(String(254), index=True)
+    requested_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
     )
 
 
@@ -113,6 +147,8 @@ class Project(Base):
     )
     name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
     is_public: Mapped[bool] = mapped_column(Boolean())
+    constraints: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
+
     volunteers: Mapped[list["Volunteer"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
@@ -131,7 +167,6 @@ class Project(Base):
     commissions: Mapped[list["Commission"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
-
     groups: Mapped[list["VolunteerGroup"]] = relationship(
         back_populates="project", cascade="all, delete-orphan", passive_deletes=True
     )
@@ -146,36 +181,27 @@ class Commission(Base):
     )
     project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="commissions")
-    name: Mapped[str] = mapped_column(String(128), nullable=False)  # e.g. "Bar", "Accueil"
+    name: Mapped[str] = mapped_column(String(128), nullable=False)
 
     templates: Mapped[list["SlotTemplate"]] = relationship(back_populates="commission")
-    members: Mapped[list["CommissionMember"]] = relationship(
-        back_populates="commission", cascade="all, delete-orphan"
+    members: Mapped[list["User"]] = relationship(
+        secondary=association_table_commission_member, back_populates="commissions"
     )
 
     @hybrid_property
     def contacts(self) -> list[dict]:
         """Read-only, derived from members' own profile — never stored redundantly."""
-        return [{"name": m.user.name, "phone_number": m.user.phone_number} for m in self.members]
+        return [{"name": m.name, "phone_number": m.phone_number} for m in self.members]
+
+    @hybrid_property
+    def members_id(self) -> list[UUID]:
+        return [u.id for u in self.members]
 
     @hybrid_property
     def templates_id(self) -> list[UUID]:
         return [t.id for t in self.templates]
 
 
-class CommissionMember(Base):
-    __tablename__ = "commission_members"
-    commission_id: Mapped[UUID] = mapped_column(
-        ForeignKey("commissions.id", ondelete="CASCADE"), primary_key=True
-    )
-    user_id: Mapped[UUID] = mapped_column(
-        ForeignKey("user_model.id", ondelete="CASCADE"), primary_key=True
-    )
-
-    commission: Mapped["Commission"] = relationship(back_populates="members")
-    user: Mapped["User"] = relationship(back_populates="commissions")
-
-
 association_table_volunteer_slot = Table(
     "association_volunteer_slot",
     Base.metadata,

+ 13 - 0
app/schemas/objects.py

@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+
+
+class PlanningConstraints(BaseModel):
+    dejeuner_start: int = 11
+    dejeuner_end: int = 14
+    diner_start: int = 19
+    diner_end: int = 22
+    nuit_start: int = 22
+    nuit_end: int = 10
+    duree_repas_h: float = 1.0
+    duree_nuit_h: float = 8.0
+    duree_pause_h: float = 0.5

+ 20 - 1
app/schemas/requests.py

@@ -4,6 +4,7 @@ from typing import Literal
 from pydantic import UUID4, BaseModel, EmailStr, Field
 
 from app.models import OrgRole
+from app.schemas.objects import PlanningConstraints
 
 
 class BaseRequest(BaseModel):
@@ -11,6 +12,15 @@ class BaseRequest(BaseModel):
     pass
 
 
+class ForgotPasswordRequest(BaseRequest):
+    email: EmailStr
+
+
+class SetPasswordWithTokenRequest(BaseRequest):
+    token: str
+    password: str
+
+
 class RefreshTokenRequest(BaseRequest):
     refresh_token: str
 
@@ -21,6 +31,13 @@ class UserCreateRequest(BaseRequest):
     name: str
 
 
+class UserInviteRequest(BaseRequest):
+    email: EmailStr
+    name: str
+    phone_number: str | None = None
+    org_role: OrgRole = OrgRole.RESPO_COMMISSION
+
+
 class UserUpdatePasswordRequest(BaseRequest):
     password: str
 
@@ -68,6 +85,8 @@ class ProjectCreateRequest(BaseRequest):
         description="Allow anyone to access the information about this project without login",
     )
 
+class ProjectConstraintUpdateRequest(PlanningConstraints):
+    pass
 
 class ProjectUpdateRequest(BaseRequest):
     name: str
@@ -92,7 +111,7 @@ class ProjectSMSBatchRequest(BaseRequest):
      - {description} slot.description
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
+     - {respo} slot.responsible_override
      - {prenom} volunteer.name
      - {nom} volunteer.surname""",
         examples=["Bonjour {prenom},\nTon créneau {titre} commence à {debut}.\nla com bénévole"],

+ 6 - 9
app/schemas/responses.py

@@ -3,6 +3,8 @@ from enum import Enum
 
 from pydantic import UUID4, BaseModel, ConfigDict, EmailStr
 
+from app.schemas.objects import PlanningConstraints
+
 
 class BaseResponse(BaseModel):
     model_config = ConfigDict(from_attributes=True)
@@ -25,8 +27,6 @@ class AccessTokenResponse(BaseResponse):
 
 
 # ---- Organization ----
-
-
 class OrganizationResponse(BaseResponse):
     id: str
     name: str
@@ -51,7 +51,6 @@ class OrganizationDetailResponse(OrganizationResponse):
 
 
 class UserOrganizationResponse(BaseResponse):
-    organization_id: str
     organization: OrganizationResponse
     role: str
 
@@ -63,17 +62,13 @@ class UserResponse(BaseResponse):
     phone_number: str | None = None
     global_role: str
     organizations: list[UserOrganizationResponse] = []
-
-
-class CommissionContactResponse(BaseResponse):
-    name: str
-    phone_number: str | None = None
+    must_change_password: bool
 
 
 class CommissionResponse(BaseObjectResponse):
     name: str
-    contacts: list[CommissionContactResponse] = []
     templates_id: list[UUID4] = []
+    members_id: list[UUID4] = []
 
 
 class VolunteerGroupResponse(BaseObjectResponse):
@@ -134,6 +129,8 @@ class ProjectResponse(BaseObjectResponse):
     tags: list[TagResponse]
     templates: list[TemplateResponse]
     groups: list[VolunteerGroupResponse] = []
+    commissions: list[CommissionResponse] = []
+    constraints: PlanningConstraints
     is_public: bool
 
 

+ 236 - 44
app/tests/test_commissions.py

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
 
 from app.core.session import session as session_maker
 from app.main import app
-from app.models import Commission, CommissionMember, Organization, OrgRole, Project, User
+from app.models import Commission, Organization, OrgRole, Project, User, UserOrganization
 from app.tests.shared_access import SharedProjectAccessTests
 
 pytestmark = pytest.mark.asyncio
@@ -49,32 +49,20 @@ def default_commission(default_project: Project) -> Commission:
         return commission
 
 
+route_kwarg = {"project_id": "PROJECT", "commission_id": "COMMISSION"}
 COMMISSION_ROUTES = [
     ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
     ("POST", "create_commission", {"project_id": "PROJECT"}, {"name": "Test"}),
-    ("GET", "get_commission", {"project_id": "PROJECT", "commission_id": "COMMISSION"}, None),
-    (
-        "PATCH",
-        "update_commission",
-        {"project_id": "PROJECT", "commission_id": "COMMISSION"},
-        {"name": "Updated"},
-    ),
-    ("DELETE", "delete_commission", {"project_id": "PROJECT", "commission_id": "COMMISSION"}, None),
+    ("GET", "get_commission", route_kwarg, None),
+    ("POST", "update_commission", route_kwarg, {"name": "Updated"}),
+    ("DELETE", "delete_commission", route_kwarg, None),
+    ("POST", "add_members_to_commission", route_kwarg, {"user_ids": ["USER"]}),
+    ("DELETE", "remove_member_from_commission", {**route_kwarg, "user_id": "USER"}, None),
     (
         "POST",
-        "add_members_to_commission",
-        {"project_id": "PROJECT", "commission_id": "COMMISSION"},
-        {"user_ids": ["USER"]},
-    ),
-    (
-        "DELETE",
-        "remove_member_from_commission",
-        {
-            "project_id": "PROJECT",
-            "commission_id": "COMMISSION",
-            "user_id": "USER",
-        },
-        None,
+        "invite_commission_member",
+        route_kwarg,
+        {"email": "crosscut@test.com", "name": "Cross Cut", "password": "x"},
     ),
 ]
 
@@ -195,7 +183,7 @@ class TestUpdateCommission:
         session: Session,
     ):
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
                 "update_commission",
                 project_id=default_project.id,
@@ -221,7 +209,7 @@ class TestDeleteCommission:
     ):
         u1, _ = two_users
         commission = session.get(Commission, default_commission.id)
-        commission.members.append(CommissionMember(user_id=u1.id))
+        commission.members.append(u1)
         session.commit()
 
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
@@ -238,17 +226,6 @@ class TestDeleteCommission:
         assert session.get(Commission, default_commission.id) is None
         # User should still exist
         assert session.get(User, u1.id) is not None
-        # Association row should be cascaded
-        assoc = (
-            session.execute(
-                select(CommissionMember).where(
-                    CommissionMember.commission_id == default_commission.id
-                )
-            )
-            .scalars()
-            .all()
-        )
-        assert len(assoc) == 0
 
 
 class TestCommissionMembership:
@@ -273,10 +250,9 @@ class TestCommissionMembership:
         )
         assert response.status_code == 200
         data = response.json()
-        assert len(data["contacts"]) == 2
-        names = [contact["name"] for contact in data["contacts"]]
-        assert "Arthur Pendragon" in names
-        assert "Merlin Enchanteur" in names
+        assert len(data["members_id"]) == 2
+        assert str(u1.id) in data["members_id"]
+        assert str(u2.id) in data["members_id"]
 
     async def test_add_is_idempotent(
         self,
@@ -309,7 +285,7 @@ class TestCommissionMembership:
             json={"user_ids": [str(u1.id)]},
         )
         assert response.status_code == 200
-        assert len(response.json()["contacts"]) == 1
+        assert len(response.json()["members_id"]) == 1
 
     async def test_remove_member_from_commission(
         self,
@@ -322,8 +298,9 @@ class TestCommissionMembership:
     ):
         u1, u2 = two_users
         commission = session.get(Commission, default_commission.id)
-        commission.members.append(CommissionMember(user_id=u1.id))
-        commission.members.append(CommissionMember(user_id=u2.id))
+        assert commission is not None
+        commission.members.append(u1)
+        commission.members.append(u2)
         session.commit()
 
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
@@ -339,5 +316,220 @@ class TestCommissionMembership:
 
         assert response.status_code == 200
         data = response.json()
-        assert len(data["contacts"]) == 1
-        assert data["contacts"][0]["name"] == u2.name
+        assert len(data["members_id"]) == 1
+        assert data["members_id"][0] == str(u2.id)
+
+
+class TestInviteCommissionMember:
+    @pytest.mark.parametrize("role", MANAGE_ROLES)
+    async def test_creates_account_and_adds_to_commission(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        session: Session,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "lancelot@test.com",
+                "name": "Lancelot du Lac",
+                "password": "TempPass123!",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+        data = response.json()
+
+        user = session.scalar(select(User).where(User.email == "lancelot@test.com"))
+        assert user is not None
+        assert user.name == "Lancelot du Lac"
+        assert user.must_change_password is True
+        assert str(user.id) in data["members_id"]
+
+        membership = session.get(UserOrganization, (user.id, default_project.organization_id))
+        assert membership is not None
+        assert membership.role == OrgRole.RESPO_COMMISSION
+
+    @pytest.mark.parametrize("role", FORBIDDEN_MANAGE_ROLES)
+    async def test_forbidden_manage_roles(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        role,
+    ):
+        _, headers = make_org_user(role=role)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={"email": "nope@test.com", "name": "Nope", "password": "x"},
+        )
+        assert response.status_code == 403
+
+    async def test_existing_user_not_recreated_and_password_untouched(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, _ = two_users
+        original_hash = u1.hashed_password
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": u1.email,
+                "name": "Ignored Name Change",
+                "password": "AttackerChosenPassword!",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+
+        refreshed = session.get(User, u1.id)
+        assert refreshed.hashed_password == original_hash  # untouched
+        assert refreshed.name == "Arthur Pendragon"  # untouched
+        assert refreshed.must_change_password is False  # never set on existing account
+        assert str(u1.id) in response.json()["members_id"]
+
+    async def test_existing_org_membership_role_not_overwritten(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        two_users,
+        make_org_user,
+        session: Session,
+    ):
+        u1, _ = two_users
+        session.add(
+            UserOrganization(
+                user_id=u1.id,
+                organization_id=default_project.organization_id,
+                role=OrgRole.RESPO_BENEVOLE,
+            )
+        )
+        session.commit()
+
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            # requesting respo_commission, but they're already respo_benevole -- must not downgrade
+            json={
+                "email": u1.email,
+                "name": u1.name,
+                "password": "x",
+                "org_role": "respo_commission",
+            },
+        )
+        assert response.status_code == 200
+
+        membership = session.get(UserOrganization, (u1.id, default_project.organization_id))
+        assert membership.role == OrgRole.RESPO_BENEVOLE  # unchanged
+
+    async def test_respo_benevole_cannot_grant_org_admin(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "wouldbe@test.com",
+                "name": "Would Be Admin",
+                "password": "x",
+                "org_role": "org_admin",
+            },
+        )
+        assert response.status_code == 403
+
+    async def test_org_admin_can_grant_org_admin(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+        session: Session,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        response = await client.post(
+            app.url_path_for(
+                "invite_commission_member",
+                project_id=default_project.id,
+                commission_id=default_commission.id,
+            ),
+            headers=headers,
+            json={
+                "email": "newadmin@test.com",
+                "name": "New Admin",
+                "password": "x",
+                "org_role": "org_admin",
+            },
+        )
+        assert response.status_code == 200
+        user = session.scalar(select(User).where(User.email == "newadmin@test.com"))
+        membership = session.get(UserOrganization, (user.id, default_project.organization_id))
+        assert membership.role == OrgRole.ORG_ADMIN
+
+    async def test_invite_is_idempotent_on_commission_membership(
+        self,
+        client: AsyncClient,
+        default_project: Project,
+        default_commission: Commission,
+        make_org_user,
+    ):
+        _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
+        payload = {
+            "email": "guenievre@test.com",
+            "name": "Guenièvre",
+            "password": "x",
+            "org_role": "respo_commission",
+        }
+        for _ in range(2):
+            response = await client.post(
+                app.url_path_for(
+                    "invite_commission_member",
+                    project_id=default_project.id,
+                    commission_id=default_commission.id,
+                ),
+                headers=headers,
+                json=payload,
+            )
+            assert response.status_code == 200
+        assert len(response.json()["members_id"]) == 1

+ 105 - 36
app/tests/test_organizations.py

@@ -40,7 +40,11 @@ class TestListOrganizations:
         assert response.json() == []
 
     async def test_super_admin_sees_all_orgs(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
         session.add(other_org)
@@ -56,7 +60,9 @@ class TestListOrganizations:
 
 class TestCreateOrganization:
     async def test_requires_auth(self, client: AsyncClient):
-        response = await client.post(app.url_path_for("create_organization"), json={"name": "New Org"})
+        response = await client.post(
+            app.url_path_for("create_organization"), json={"name": "New Org"}
+        )
         assert response.status_code == 401
 
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
@@ -80,7 +86,11 @@ class TestCreateOrganization:
         data = response.json()
         assert data["name"] == "New Org"
 
-        org = session.execute(select(Organization).where(Organization.name == "New Org")).scalars().first()
+        org = (
+            session.execute(select(Organization).where(Organization.name == "New Org"))
+            .scalars()
+            .first()
+        )
         assert org is not None
 
     async def test_duplicate_name_rejected(
@@ -96,7 +106,9 @@ class TestCreateOrganization:
 
     async def test_missing_name_validation_error(self, client: AsyncClient, make_org_user):
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
-        response = await client.post(app.url_path_for("create_organization"), headers=headers, json={})
+        response = await client.post(
+            app.url_path_for("create_organization"), headers=headers, json={}
+        )
         assert response.status_code == 422
 
 
@@ -113,7 +125,8 @@ class TestGetOrganization:
     ):
         _, headers = make_org_user(role=role)
         response = await client.get(
-            app.url_path_for("get_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 200
         assert response.json()["id"] == default_organization_id
@@ -123,7 +136,8 @@ class TestGetOrganization:
     ):
         _, headers = make_org_user(role=None)
         response = await client.get(
-            app.url_path_for("get_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 403
 
@@ -132,7 +146,8 @@ class TestGetOrganization:
     ):
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         response = await client.get(
-            app.url_path_for("get_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 200
 
@@ -141,7 +156,8 @@ class TestGetOrganization:
     ):
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.get(
-            app.url_path_for("get_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("get_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 200
         memberships = response.json()["memberships"]
@@ -162,7 +178,7 @@ class TestUpdateOrganization:
         self, client: AsyncClient, default_organization: Organization, make_org_user, role
     ):
         _, headers = make_org_user(role=role)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_organization", organization_id=default_organization_id),
             headers=headers,
             json={"name": "Renamed"},
@@ -170,10 +186,14 @@ class TestUpdateOrganization:
         assert response.status_code == 403
 
     async def test_super_admin_renames(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_organization", organization_id=default_organization_id),
             headers=headers,
             json={"name": "Renamed"},
@@ -184,7 +204,7 @@ class TestUpdateOrganization:
 
     async def test_not_found(self, client: AsyncClient, make_org_user):
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_organization", organization_id=uuid.uuid4()),
             headers=headers,
             json={"name": "Nope"},
@@ -199,7 +219,8 @@ class TestDeleteOrganization:
     ):
         _, headers = make_org_user(role=role)
         response = await client.delete(
-            app.url_path_for("delete_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("delete_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 403
 
@@ -208,12 +229,14 @@ class TestDeleteOrganization:
     ):
         _, headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         response = await client.delete(
-            app.url_path_for("delete_organization", organization_id=default_organization_id), headers=headers
+            app.url_path_for("delete_organization", organization_id=default_organization_id),
+            headers=headers,
         )
         assert response.status_code == 200
         assert session.get(Organization, default_organization_id) is None
 
         from app.models import Project
+
         assert session.get(Project, default_project.id) is None
 
     async def test_not_found(self, client: AsyncClient, make_org_user):
@@ -223,6 +246,7 @@ class TestDeleteOrganization:
         )
         assert response.status_code == 404
 
+
 class TestAddMember:
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
     async def test_non_super_admin_forbidden(
@@ -237,7 +261,11 @@ class TestAddMember:
         assert response.status_code == 403
 
     async def test_super_admin_adds_existing_user(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         target_user, _ = make_org_user(role=None)  # exists, no membership yet
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
@@ -257,7 +285,11 @@ class TestAddMember:
         assert membership.role == OrgRole.RESPO_COMMISSION
 
     async def test_user_can_belong_to_multiple_orgs(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         """Confirms the multi-org membership model -- no unique(user_id) constraint."""
         target_user, _ = make_org_user(role=OrgRole.ORG_ADMIN)  # already in default_organization
@@ -273,9 +305,13 @@ class TestAddMember:
         )
         assert response.status_code == 200
 
-        memberships = session.execute(
-            select(UserOrganization).where(UserOrganization.user_id == target_user.id)
-        ).scalars().all()
+        memberships = (
+            session.execute(
+                select(UserOrganization).where(UserOrganization.user_id == target_user.id)
+            )
+            .scalars()
+            .all()
+        )
         assert len(memberships) == 2
 
     async def test_unknown_user_id_not_found(
@@ -313,6 +349,7 @@ class TestAddMember:
         )
         assert response.status_code == 404
 
+
 class TestUpdateMemberRole:
     @pytest.mark.parametrize("role", [OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION, None])
     async def test_insufficient_role_forbidden(
@@ -320,9 +357,11 @@ class TestUpdateMemberRole:
     ):
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, headers = make_org_user(role=role)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
-                "update_member_role", organization_id=default_organization_id, user_id=target_user.id
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
             ),
             headers=headers,
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -330,14 +369,20 @@ class TestUpdateMemberRole:
         assert response.status_code == 403
 
     async def test_org_admin_of_same_org_can_change_role(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, org_admin_headers = make_org_user(role=OrgRole.ORG_ADMIN)
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
-                "update_member_role", organization_id=default_organization_id, user_id=target_user.id
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
             ),
             headers=org_admin_headers,
             json={"role": OrgRole.RESPO_BENEVOLE.value},
@@ -349,27 +394,41 @@ class TestUpdateMemberRole:
         assert membership.role == OrgRole.RESPO_BENEVOLE
 
     async def test_org_admin_of_different_org_forbidden(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         """An org_admin has no authority over an organization they don't belong to."""
         other_org = Organization(id=uuid.uuid4(), name="Other Org")
         session.add(other_org)
         session.commit()
 
-        target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)  # member of default_organization
+        target_user, _ = make_org_user(
+            role=OrgRole.RESPO_COMMISSION
+        )  # member of default_organization
 
         # org_admin, but of other_org, not default_organization
-        outsider = User(id=uuid.uuid4(), email="outsider@test.com", hashed_password="x", name="Outsider")
+        outsider = User(
+            id=uuid.uuid4(), email="outsider@test.com", hashed_password="x", name="Outsider"
+        )
         session.add(outsider)
         session.commit()
-        session.add(UserOrganization(user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN))
+        session.add(
+            UserOrganization(
+                user_id=outsider.id, organization_id=other_org.id, role=OrgRole.ORG_ADMIN
+            )
+        )
         session.commit()
         outsider_token = security.create_jwt_token(str(outsider.id), 60 * 60 * 24, refresh=False)[0]
         outsider_headers = {"Authorization": f"Bearer {outsider_token}"}
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
-                "update_member_role", organization_id=default_organization_id, user_id=target_user.id
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
             ),
             headers=outsider_headers,
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -377,14 +436,20 @@ class TestUpdateMemberRole:
         assert response.status_code == 403
 
     async def test_super_admin_changes_role(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
-                "update_member_role", organization_id=default_organization_id, user_id=target_user.id
+                "update_member_role",
+                organization_id=default_organization_id,
+                user_id=target_user.id,
             ),
             headers=admin_headers,
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -401,7 +466,7 @@ class TestUpdateMemberRole:
         non_member, _ = make_org_user(role=None)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
                 "update_member_role", organization_id=default_organization_id, user_id=non_member.id
             ),
@@ -427,7 +492,11 @@ class TestRemoveMember:
         assert response.status_code == 403
 
     async def test_super_admin_removes_member(
-        self, client: AsyncClient, default_organization: Organization, make_org_user, session: Session
+        self,
+        client: AsyncClient,
+        default_organization: Organization,
+        make_org_user,
+        session: Session,
     ):
         target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
@@ -455,4 +524,4 @@ class TestRemoveMember:
             ),
             headers=admin_headers,
         )
-        assert response.status_code == 404
+        assert response.status_code == 404

+ 184 - 0
app/tests/test_password_reset.py

@@ -0,0 +1,184 @@
+import hashlib
+import uuid
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.core.security import get_password_hash, verify_password
+from app.core.session import session as session_maker
+from app.models import PasswordResetToken, User
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+def existing_user() -> User:
+    with session_maker() as db:
+        user = User(
+            email="perceval@test.com",
+            hashed_password=get_password_hash("OldPassword123"),
+            name="Perceval",
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
+        return user
+
+
+def make_token(
+    session: Session, user: User, *, purpose="reset", expires_in=timedelta(hours=1), used=False
+):
+    raw = "raw-token-" + uuid.uuid4().hex
+    token_hash = hashlib.sha256(raw.encode()).hexdigest()
+    record = PasswordResetToken(
+        user_id=user.id,
+        token_hash=token_hash,
+        expires_at=datetime.now(UTC) + expires_in,
+        purpose=purpose,
+        used_at=datetime.now(UTC) if used else None,
+    )
+    session.add(record)
+    session.commit()
+    return raw
+
+
+class TestForgotPassword:
+    async def test_existing_email_returns_204_and_creates_token(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        response = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        assert response.status_code == 204
+        token = session.scalar(
+            select(PasswordResetToken).where(PasswordResetToken.user_id == existing_user.id)
+        )
+        assert token is not None
+        assert token.purpose == "reset"
+
+    async def test_unknown_email_also_returns_204_no_enumeration(self, client: AsyncClient):
+        response = await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert response.status_code == 204
+
+    async def test_unknown_email_creates_no_token(self, client: AsyncClient, session: Session):
+        await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert session.scalar(select(PasswordResetToken)) is None
+
+    async def test_response_body_identical_for_both_cases(
+        self, client: AsyncClient, existing_user: User
+    ):
+        # guards against a future refactor leaking a distinguishable signal
+        r1 = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        r2 = await client.post("/users/forgot-password", json={"email": "nobody@test.com"})
+        assert r1.status_code == r2.status_code == 204
+        assert r1.content == r2.content == b""
+
+
+class TestSetPasswordWithToken:
+    async def test_valid_token_sets_password(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+        refreshed = session.get(User, existing_user.id)
+        assert verify_password("NewSecurePass456", refreshed.hashed_password)
+
+    async def test_valid_token_clears_must_change_password(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        existing_user.must_change_password = True
+        session.merge(existing_user)
+        session.commit()
+        raw_token = make_token(session, existing_user)
+
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+        assert session.get(User, existing_user.id).must_change_password is False
+
+    async def test_token_marked_used_after_success(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
+        record = session.scalar(
+            select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
+        )
+        assert record.used_at is not None
+
+    async def test_token_cannot_be_reused(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user)
+        await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "First12345"}
+        )
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "Second67890"}
+        )
+        assert response.status_code == 400
+
+    async def test_expired_token_rejected(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user, expires_in=timedelta(hours=-1))
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 400
+
+    async def test_already_used_token_rejected(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(session, existing_user, used=True)
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 400
+
+    async def test_unknown_token_rejected(self, client: AsyncClient):
+        response = await client.post(
+            "/users/set-password",
+            json={"token": "not-a-real-token", "password": "NewSecurePass456"},
+        )
+        assert response.status_code == 400
+
+    async def test_invite_purpose_token_also_works(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        raw_token = make_token(
+            session, existing_user, purpose="invite", expires_in=timedelta(days=7)
+        )
+        response = await client.post(
+            "/users/set-password", json={"token": raw_token, "password": "NewSecurePass456"}
+        )
+        assert response.status_code == 200
+
+
+class TestForgotPasswordRateLimit:
+    async def test_exceeding_limit_stops_sending_but_still_204s(
+        self, client: AsyncClient, existing_user: User, session: Session
+    ):
+        for _ in range(3):
+            await client.post("/users/forgot-password", json={"email": existing_user.email})
+        tokens_before = session.scalar(
+            select(func.count())
+            .select_from(PasswordResetToken)
+            .where(PasswordResetToken.user_id == existing_user.id)
+        )
+        response = await client.post("/users/forgot-password", json={"email": existing_user.email})
+        tokens_after = session.scalar(
+            select(func.count())
+            .select_from(PasswordResetToken)
+            .where(PasswordResetToken.user_id == existing_user.id)
+        )
+        assert response.status_code == 204
+        assert tokens_after == tokens_before

+ 1 - 2
app/tests/test_slots.py

@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session
 from app.main import app
 from app.models import (
     Commission,
-    CommissionMember,
     OrgRole,
     Project,
     Slot,
@@ -33,7 +32,7 @@ def commission_and_member(session: Session, default_public_project: Project, mak
     session.add(commission)
     session.commit()
     user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
-    session.add(CommissionMember(commission_id=commission.id, user_id=user.id))
+    commission.members.append(user)
     session.commit()
     return commission, user, headers
 

+ 2 - 7
app/tests/test_sms_batch.py

@@ -8,7 +8,6 @@ from sqlalchemy.orm import Session
 from app.main import app
 from app.models import (
     Commission,
-    CommissionMember,
     OrgRole,
     Project,
     Slot,
@@ -464,12 +463,8 @@ class TestSmsPlaceholders:
         )
         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),
-            ]
-        )
+        commission.members.append(member1)
+        commission.members.append(member2)
         session.commit()
         slot_template = SlotTemplate(
             project_id=default_public_project.id,

+ 2 - 2
app/tests/test_templates.py

@@ -5,7 +5,7 @@ from httpx import AsyncClient
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Commission, CommissionMember, OrgRole, Project, Slot, SlotTemplate
+from app.models import Commission, OrgRole, Project, Slot, SlotTemplate
 from app.tests.conftest import (
     default_project_id,
     default_slot_id,
@@ -27,7 +27,7 @@ def commission_and_member(session: Session, default_public_project: Project, mak
     session.add(commission)
     session.commit()
     user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
-    session.add(CommissionMember(commission_id=commission.id, user_id=user.id))
+    commission.members.append(user)
     session.commit()
     return commission, user, headers
 

+ 5 - 5
app/tests/test_users.py

@@ -110,7 +110,7 @@ class TestReadCurrentUser:
 
 class TestUpdateCurrentUserProfile:
     async def test_requires_auth(self, client: AsyncClient):
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"), json={"name": "New Name"}
         )
         assert response.status_code == 401
@@ -118,7 +118,7 @@ class TestUpdateCurrentUserProfile:
     async def test_updates_name(
         self, client: AsyncClient, default_user_headers: dict, session: Session
     ):
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             json={"name": "Geralt of Rivia"},
@@ -127,7 +127,7 @@ class TestUpdateCurrentUserProfile:
         assert response.json()["name"] == "Geralt of Rivia"
 
     async def test_updates_phone_number(self, client: AsyncClient, default_user_headers: dict):
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             json={"phone_number": "0601020304"},
@@ -138,12 +138,12 @@ class TestUpdateCurrentUserProfile:
     async def test_partial_update_does_not_clear_other_field(
         self, client: AsyncClient, default_user_headers: dict
     ):
-        await client.patch(
+        await client.post(
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             json={"name": "Geralt", "phone_number": "0601020304"},
         )
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             json={"name": "Geralt Updated"},

+ 2 - 2
app/tests/test_volunteer_groups.py

@@ -60,7 +60,7 @@ VOLUNTEER_GROUP_ROUTES = [
     ("GET", "list_project_groups", route_kwargs, None),
     ("POST", "create_group", route_kwargs, {"name": "Test"}),
     ("GET", "get_group", route_kwargs_2, None),
-    ("PATCH", "update_group", route_kwargs_2, {"name": "Updated"}),
+    ("POST", "update_group", route_kwargs_2, {"name": "Updated"}),
     ("DELETE", "delete_group", route_kwargs_2, None),
     ("POST", "add_volunteers_to_group", route_kwargs_2, {"volunteer_ids": []}),
     ("DELETE", "remove_volunteer_from_group", {**route_kwargs_2, "volunteer_id": "VOL"}, None),
@@ -208,7 +208,7 @@ class TestUpdateGroup:
         session: Session,
     ):
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
                 "update_group", project_id=default_project.id, group_id=default_group.id
             ),

+ 17 - 1
poetry.lock

@@ -1,5 +1,21 @@
 # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
 
+[[package]]
+name = "aiosmtplib"
+version = "5.1.2"
+description = "asyncio SMTP client"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+    {file = "aiosmtplib-5.1.2-py3-none-any.whl", hash = "sha256:070d467cc329dafd0af59108ba5d217d973cba10309910fed359a2a7bfb52d7a"},
+    {file = "aiosmtplib-5.1.2.tar.gz", hash = "sha256:04a0ea3c678f5b719f998f290dce010ca512e1385836d3944206299df03b060f"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
+uvloop = ["uvloop (>=0.18)"]
+
 [[package]]
 name = "alembic"
 version = "1.16.5"
@@ -2222,4 +2238,4 @@ files = [
 [metadata]
 lock-version = "2.1"
 python-versions = "^3.11"
-content-hash = "9f43a3291386ab283e510802e12aaee465e8c5bdada73e746176372de900a8cc"
+content-hash = "4283413c272bc2cdda2f7d7c8ee1dff14f83335daed0fa7afda5c289d9fa34b4"

+ 1 - 0
pyproject.toml

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