Kaynağa Gözat

test: implement project constraints

clovis 1 hafta önce
ebeveyn
işleme
48990d9594

+ 8 - 0
.env.example

@@ -1,3 +1,4 @@
+FRONT_END_URL="http://localhost:8000"
 SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG
 SECRET_KEY=DVnFmhwvjEhJZpuhndxjhlezxQPJmBIIkMDEmFREWQADPcUnrG
 ENVIRONMENT=DEV
 ENVIRONMENT=DEV
 ACCESS_TOKEN_EXPIRE_MINUTES=11520
 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_EMAIL=example@example.com
 FIRST_SUPERUSER_PASSWORD=OdLknKQJMUwuhpAVHvRC
 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 import config, security
 from app.core.session import session
 from app.core.session import session
 from app.models import (
 from app.models import (
-    CommissionMember,
     GlobalRole,
     GlobalRole,
     OrgRole,
     OrgRole,
     Project,
     Project,
     User,
     User,
     UserOrganization,
     UserOrganization,
+    association_table_commission_member,
 )
 )
 
 
 reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="auth/access-token")
 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:
 def _is_commission_member(session: Session, user_id: str, commission_id: str | None) -> bool:
     if commission_id is None:
     if commission_id is None:
         return False
         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):
 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 uuid import UUID
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 
 from app.api import deps
 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 (
 from app.schemas.requests import (
     CommissionCreateRequest,
     CommissionCreateRequest,
     CommissionMembershipRequest,
     CommissionMembershipRequest,
     CommissionUpdateRequest,
     CommissionUpdateRequest,
+    UserInviteRequest,
 )
 )
 from app.schemas.responses import CommissionResponse
 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:
 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")
         raise HTTPException(status_code=404, detail="Commission not found")
     return commission
     return commission
 
 
@@ -66,7 +75,7 @@ async def get_commission(
     return _get_commission_or_404(session, project_id, commission_id)
     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(
 async def update_commission(
     project_id: UUID,
     project_id: UUID,
     commission_id: UUID,
     commission_id: UUID,
@@ -111,17 +120,13 @@ async def add_members_to_commission(
     if len(valid_users) != len(payload.user_ids):
     if len(valid_users) != len(payload.user_ids):
         raise HTTPException(status_code=400, detail="One or more invalid 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:
     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.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)
 @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"""
     """Remove a single user from a commission"""
     commission = _get_commission_or_404(session, project_id, commission_id)
     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:
     if member_to_remove:
         session.delete(member_to_remove)
         session.delete(member_to_remove)
         session.commit()
         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
     return org
 
 
 
 
-@router.patch("/{organization_id}", response_model=OrganizationResponse)
+@router.post("/{organization_id}", response_model=OrganizationResponse)
 async def update_organization(
 async def update_organization(
     organization_id: UUID,
     organization_id: UUID,
     edit_org: OrganizationRequest,
     edit_org: OrganizationRequest,
@@ -117,9 +117,6 @@ async def delete_organization(
     session.commit()
     session.commit()
 
 
 
 
-# ---- Membership management ----
-
-
 @router.post("/{organization_id}/members", response_model=OrganizationMembershipResponse)
 @router.post("/{organization_id}/members", response_model=OrganizationMembershipResponse)
 async def add_member(
 async def add_member(
     organization_id: UUID,
     organization_id: UUID,
@@ -152,7 +149,7 @@ async def add_member(
     return membership
     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(
 async def update_member_role(
     organization_id: UUID,
     organization_id: UUID,
     user_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 fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, selectinload
 
 
 from app.api import deps
 from app.api import deps
 from app.api.utils import assert_project_exists_or_404, get_project_or_404
 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.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
 from app.models import (
 from app.models import (
+    Commission,
     GlobalRole,
     GlobalRole,
     OrgRole,
     OrgRole,
     Project,
     Project,
@@ -19,8 +20,10 @@ from app.models import (
     Sms,
     Sms,
     User,
     User,
     Volunteer,
     Volunteer,
+    VolunteerGroup,
 )
 )
 from app.schemas.requests import (
 from app.schemas.requests import (
+    ProjectConstraintUpdateRequest,
     ProjectCreateRequest,
     ProjectCreateRequest,
     ProjectImportGsheetRequest,
     ProjectImportGsheetRequest,
     ProjectSMSBatchRequest,
     ProjectSMSBatchRequest,
@@ -104,7 +107,22 @@ async def get_project(
     session: Session = Depends(deps.get_session),
     session: Session = Depends(deps.get_session),
 ):
 ):
     """Get a project"""
     """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:
     if project is None:
         raise HTTPException(status_code=404, detail="Project not found")
         raise HTTPException(status_code=404, detail="Project not found")
     return project
     return project
@@ -125,6 +143,22 @@ async def update_project(
     return p
     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)
 @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
 async def update_project_from_gsheet(
 async def update_project_from_gsheet(
     project_id: UUID,
     project_id: UUID,
@@ -165,7 +199,7 @@ async def update_project_from_gsheet(
     tags_map = {tag.title: tag for tag in p.tags}
     tags_map = {tag.title: tag for tag in p.tags}
 
 
     for _, row in data.creneauData.iterrows():
     for _, row in data.creneauData.iterrows():
-        # NOTE: responsible_contact stays free-text on gsheet import for now —
+        # NOTE: responsible_override stays free-text on gsheet import for now —
         # linking to a real Commission requires matching against commission
         # linking to a real Commission requires matching against commission
         # names/members, which the sheet doesn't carry. Revisit once the
         # names/members, which the sheet doesn't carry. Revisit once the
         # gsheet format itself is updated to reference commissions.
         # gsheet format itself is updated to reference commissions.
@@ -174,7 +208,7 @@ async def update_project_from_gsheet(
             title=row.title,
             title=row.title,
             description=row.description,
             description=row.description,
             place=row.lieu,
             place=row.lieu,
-            responsible_contact=row.responsable,
+            responsible_override=row.responsable,
         )
         )
 
 
         for s_tag in row.tags.split(","):
         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 uuid import UUID
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
+from fastapi.params import Query
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -25,10 +27,15 @@ async def list_project_sms(
     project_id: UUID,
     project_id: UUID,
     current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     current_user: User = Depends(deps.require_org_role(*READ_ROLES)),
     session: Session = Depends(deps.get_session),
     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"""
     """List sms from project"""
     assert_project_exists_or_404(session, project_id)
     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()
     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 fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
 from sqlalchemy.orm import Session, selectinload
 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.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 (
 from app.schemas.requests import (
+    ForgotPasswordRequest,
+    SetPasswordWithTokenRequest,
     UserCreateRequest,
     UserCreateRequest,
     UserProfileUpdateRequest,
     UserProfileUpdateRequest,
     UserUpdatePasswordRequest,
     UserUpdatePasswordRequest,
@@ -51,7 +59,7 @@ async def read_current_user(
     return current_user
     return current_user
 
 
 
 
-@router.patch("/me", response_model=UserResponse)
+@router.post("/me", response_model=UserResponse)
 async def update_current_user_profile(
 async def update_current_user_profile(
     payload: UserProfileUpdateRequest,
     payload: UserProfileUpdateRequest,
     current_user: User = Depends(deps.get_current_user),
     current_user: User = Depends(deps.get_current_user),
@@ -114,3 +122,46 @@ async def register_new_user(
     session.commit()
     session.commit()
     session.refresh(user)
     session.refresh(user)
     return 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)
     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(
 async def update_group(
     project_id: UUID,
     project_id: UUID,
     group_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 uuid import UUID
 
 
 from fastapi import HTTPException
 from fastapi import HTTPException
@@ -5,7 +7,11 @@ from sqlalchemy import exists, select
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from sqlalchemy.sql import func
 from sqlalchemy.sql import func
 
 
-from app.models import Project
+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:
 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"""
     """Update the ORM model object from a pydantic payload dictionary"""
     for attr_name, value in payload.items():
     for attr_name, value in payload.items():
         setattr(obj, attr_name, value)
         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):
 class Settings(BaseSettings):
     # CORE SETTINGS
     # CORE SETTINGS
+    FRONT_END_URL: str
     SECRET_KEY: str
     SECRET_KEY: str
+    RESET_REQUEST_LIMIT: int = 3
+    RESET_REQUEST_WINDOW_HOUR: int = 1
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     ENVIRONMENT: Literal["DEV", "PYTEST", "STG", "PRD"] = "DEV"
     SECURITY_BCRYPT_ROUNDS: int = 12
     SECURITY_BCRYPT_ROUNDS: int = 12
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 11520  # 8 days
@@ -45,13 +48,22 @@ class Settings(BaseSettings):
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     BACKEND_CORS_ORIGINS: list[AnyHttpUrl | Literal["*"]] = []
     ALLOWED_HOSTS: list[str] = ["localhost", "127.0.0.1"]
     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, VERSION AND DESCRIPTION
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     PROJECT_NAME: str = PYPROJECT_CONTENT["name"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     VERSION: str = PYPROJECT_CONTENT["version"]
     DESCRIPTION: str = PYPROJECT_CONTENT["description"]
     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
     # POSTGRESQL DEFAULT DATABASE
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_HOSTNAME: str
     DEFAULT_DATABASE_USER: str
     DEFAULT_DATABASE_USER: str
@@ -98,5 +110,4 @@ class Settings(BaseSettings):
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
     model_config = SettingsConfigDict(env_file=f"{PROJECT_DIR}/.env", case_sensitive=True)
 
 
 
 
-
 settings: Settings = Settings()  # type: ignore
 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."""
 """Black-box security shortcuts to generate JWT tokens and password hashing and verification."""
 
 
+import hashlib
+import secrets
 import time
 import time
+from datetime import UTC, datetime, timedelta
 
 
 import jwt
 import jwt
 from passlib.context import CryptContext
 from passlib.context import CryptContext
 from pydantic import BaseModel
 from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
 
 
 from app.core import config
 from app.core import config
+from app.models import PasswordResetRequestLog
 from app.schemas.responses import AccessTokenResponse
 from app.schemas.responses import AccessTokenResponse
 
 
 JWT_ALGORITHM = "HS256"
 JWT_ALGORITHM = "HS256"
@@ -26,6 +32,15 @@ class JWTTokenPayload(BaseModel):
     expires_at: int
     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):
 def create_jwt_token(subject: str | int, exp_secs: int, refresh: bool):
     """Creates jwt access or refresh token for user.
     """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.
     It takes about 0.3s for default 12 rounds of SECURITY_BCRYPT_DEFAULT_ROUNDS.
     """
     """
     return PWD_CONTEXT.hash(password)
     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
 import uuid
 from datetime import datetime
 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 import Enum as SAEnum
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.ext.hybrid import hybrid_property
@@ -46,11 +46,20 @@ class OrgRole(str, enum.Enum):
     RESPO_COMMISSION = "respo_commission"
     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):
 class User(Base):
     __tablename__ = "user_model"
     __tablename__ = "user_model"
     id: Mapped[UUID] = uid_column()
     id: Mapped[UUID] = uid_column()
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
+    must_change_password: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)
     name: Mapped[str] = mapped_column(String(128), default="")
     name: Mapped[str] = mapped_column(String(128), default="")
     phone_number: Mapped[str | None] = 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(
@@ -60,8 +69,33 @@ class User(Base):
     organizations: Mapped[list["UserOrganization"]] = relationship(
     organizations: Mapped[list["UserOrganization"]] = relationship(
         back_populates="user", cascade="all, delete-orphan"
         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)
     name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
     is_public: Mapped[bool] = mapped_column(Boolean())
     is_public: Mapped[bool] = mapped_column(Boolean())
+    constraints: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
+
     volunteers: Mapped[list["Volunteer"]] = relationship(
     volunteers: Mapped[list["Volunteer"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
         back_populates="project", cascade="delete, delete-orphan"
     )
     )
@@ -131,7 +167,6 @@ class Project(Base):
     commissions: Mapped[list["Commission"]] = relationship(
     commissions: Mapped[list["Commission"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
         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
     )
     )
@@ -146,36 +181,27 @@ class Commission(Base):
     )
     )
     project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project_id: Mapped[UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
     project: Mapped["Project"] = relationship(back_populates="commissions")
     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")
     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
     @hybrid_property
     def contacts(self) -> list[dict]:
     def contacts(self) -> list[dict]:
         """Read-only, derived from members' own profile — never stored redundantly."""
         """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
     @hybrid_property
     def templates_id(self) -> list[UUID]:
     def templates_id(self) -> list[UUID]:
         return [t.id for t in self.templates]
         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_table_volunteer_slot = Table(
     "association_volunteer_slot",
     "association_volunteer_slot",
     Base.metadata,
     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 pydantic import UUID4, BaseModel, EmailStr, Field
 
 
 from app.models import OrgRole
 from app.models import OrgRole
+from app.schemas.objects import PlanningConstraints
 
 
 
 
 class BaseRequest(BaseModel):
 class BaseRequest(BaseModel):
@@ -11,6 +12,15 @@ class BaseRequest(BaseModel):
     pass
     pass
 
 
 
 
+class ForgotPasswordRequest(BaseRequest):
+    email: EmailStr
+
+
+class SetPasswordWithTokenRequest(BaseRequest):
+    token: str
+    password: str
+
+
 class RefreshTokenRequest(BaseRequest):
 class RefreshTokenRequest(BaseRequest):
     refresh_token: str
     refresh_token: str
 
 
@@ -21,6 +31,13 @@ class UserCreateRequest(BaseRequest):
     name: str
     name: str
 
 
 
 
+class UserInviteRequest(BaseRequest):
+    email: EmailStr
+    name: str
+    phone_number: str | None = None
+    org_role: OrgRole = OrgRole.RESPO_COMMISSION
+
+
 class UserUpdatePasswordRequest(BaseRequest):
 class UserUpdatePasswordRequest(BaseRequest):
     password: str
     password: str
 
 
@@ -68,6 +85,8 @@ class ProjectCreateRequest(BaseRequest):
         description="Allow anyone to access the information about this project without login",
         description="Allow anyone to access the information about this project without login",
     )
     )
 
 
+class ProjectConstraintUpdateRequest(PlanningConstraints):
+    pass
 
 
 class ProjectUpdateRequest(BaseRequest):
 class ProjectUpdateRequest(BaseRequest):
     name: str
     name: str
@@ -92,7 +111,7 @@ class ProjectSMSBatchRequest(BaseRequest):
      - {description} slot.description
      - {description} slot.description
      - {debut} slot.starting_time
      - {debut} slot.starting_time
      - {fin} slot.ending_ting
      - {fin} slot.ending_ting
-     - {respo} slot.responsible_contact
+     - {respo} slot.responsible_override
      - {prenom} volunteer.name
      - {prenom} volunteer.name
      - {nom} volunteer.surname""",
      - {nom} volunteer.surname""",
         examples=["Bonjour {prenom},\nTon créneau {titre} commence à {debut}.\nla com bénévole"],
         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 pydantic import UUID4, BaseModel, ConfigDict, EmailStr
 
 
+from app.schemas.objects import PlanningConstraints
+
 
 
 class BaseResponse(BaseModel):
 class BaseResponse(BaseModel):
     model_config = ConfigDict(from_attributes=True)
     model_config = ConfigDict(from_attributes=True)
@@ -25,8 +27,6 @@ class AccessTokenResponse(BaseResponse):
 
 
 
 
 # ---- Organization ----
 # ---- Organization ----
-
-
 class OrganizationResponse(BaseResponse):
 class OrganizationResponse(BaseResponse):
     id: str
     id: str
     name: str
     name: str
@@ -51,7 +51,6 @@ class OrganizationDetailResponse(OrganizationResponse):
 
 
 
 
 class UserOrganizationResponse(BaseResponse):
 class UserOrganizationResponse(BaseResponse):
-    organization_id: str
     organization: OrganizationResponse
     organization: OrganizationResponse
     role: str
     role: str
 
 
@@ -63,17 +62,13 @@ class UserResponse(BaseResponse):
     phone_number: str | None = None
     phone_number: str | None = None
     global_role: str
     global_role: str
     organizations: list[UserOrganizationResponse] = []
     organizations: list[UserOrganizationResponse] = []
-
-
-class CommissionContactResponse(BaseResponse):
-    name: str
-    phone_number: str | None = None
+    must_change_password: bool
 
 
 
 
 class CommissionResponse(BaseObjectResponse):
 class CommissionResponse(BaseObjectResponse):
     name: str
     name: str
-    contacts: list[CommissionContactResponse] = []
     templates_id: list[UUID4] = []
     templates_id: list[UUID4] = []
+    members_id: list[UUID4] = []
 
 
 
 
 class VolunteerGroupResponse(BaseObjectResponse):
 class VolunteerGroupResponse(BaseObjectResponse):
@@ -134,6 +129,8 @@ class ProjectResponse(BaseObjectResponse):
     tags: list[TagResponse]
     tags: list[TagResponse]
     templates: list[TemplateResponse]
     templates: list[TemplateResponse]
     groups: list[VolunteerGroupResponse] = []
     groups: list[VolunteerGroupResponse] = []
+    commissions: list[CommissionResponse] = []
+    constraints: PlanningConstraints
     is_public: bool
     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.core.session import session as session_maker
 from app.main import app
 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
 from app.tests.shared_access import SharedProjectAccessTests
 
 
 pytestmark = pytest.mark.asyncio
 pytestmark = pytest.mark.asyncio
@@ -49,32 +49,20 @@ def default_commission(default_project: Project) -> Commission:
         return commission
         return commission
 
 
 
 
+route_kwarg = {"project_id": "PROJECT", "commission_id": "COMMISSION"}
 COMMISSION_ROUTES = [
 COMMISSION_ROUTES = [
     ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
     ("GET", "list_project_commissions", {"project_id": "PROJECT"}, None),
     ("POST", "create_commission", {"project_id": "PROJECT"}, {"name": "Test"}),
     ("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",
         "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,
         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.post(
             app.url_path_for(
             app.url_path_for(
                 "update_commission",
                 "update_commission",
                 project_id=default_project.id,
                 project_id=default_project.id,
@@ -221,7 +209,7 @@ class TestDeleteCommission:
     ):
     ):
         u1, _ = two_users
         u1, _ = two_users
         commission = session.get(Commission, default_commission.id)
         commission = session.get(Commission, default_commission.id)
-        commission.members.append(CommissionMember(user_id=u1.id))
+        commission.members.append(u1)
         session.commit()
         session.commit()
 
 
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, headers = make_org_user(role=OrgRole.ORG_ADMIN)
@@ -238,17 +226,6 @@ class TestDeleteCommission:
         assert session.get(Commission, default_commission.id) is None
         assert session.get(Commission, default_commission.id) is None
         # User should still exist
         # User should still exist
         assert session.get(User, u1.id) is not None
         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:
 class TestCommissionMembership:
@@ -273,10 +250,9 @@ class TestCommissionMembership:
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         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(
     async def test_add_is_idempotent(
         self,
         self,
@@ -309,7 +285,7 @@ class TestCommissionMembership:
             json={"user_ids": [str(u1.id)]},
             json={"user_ids": [str(u1.id)]},
         )
         )
         assert response.status_code == 200
         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(
     async def test_remove_member_from_commission(
         self,
         self,
@@ -322,8 +298,9 @@ class TestCommissionMembership:
     ):
     ):
         u1, u2 = two_users
         u1, u2 = two_users
         commission = session.get(Commission, default_commission.id)
         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()
         session.commit()
 
 
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
@@ -339,5 +316,220 @@ class TestCommissionMembership:
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         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() == []
         assert response.json() == []
 
 
     async def test_super_admin_sees_all_orgs(
     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")
         other_org = Organization(id=str(uuid.uuid4()), name="Other Org")
         session.add(other_org)
         session.add(other_org)
@@ -56,7 +60,9 @@ class TestListOrganizations:
 
 
 class TestCreateOrganization:
 class TestCreateOrganization:
     async def test_requires_auth(self, client: AsyncClient):
     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
         assert response.status_code == 401
 
 
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
@@ -80,7 +86,11 @@ class TestCreateOrganization:
         data = response.json()
         data = response.json()
         assert data["name"] == "New Org"
         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
         assert org is not None
 
 
     async def test_duplicate_name_rejected(
     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):
     async def test_missing_name_validation_error(self, client: AsyncClient, make_org_user):
         _, 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.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
         assert response.status_code == 422
 
 
 
 
@@ -113,7 +125,8 @@ class TestGetOrganization:
     ):
     ):
         _, headers = make_org_user(role=role)
         _, headers = make_org_user(role=role)
         response = await client.get(
         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.status_code == 200
         assert response.json()["id"] == default_organization_id
         assert response.json()["id"] == default_organization_id
@@ -123,7 +136,8 @@ class TestGetOrganization:
     ):
     ):
         _, headers = make_org_user(role=None)
         _, headers = make_org_user(role=None)
         response = await client.get(
         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
         assert response.status_code == 403
 
 
@@ -132,7 +146,8 @@ class TestGetOrganization:
     ):
     ):
         _, 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("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.status_code == 200
 
 
@@ -141,7 +156,8 @@ class TestGetOrganization:
     ):
     ):
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, headers = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         response = await client.get(
         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.status_code == 200
         memberships = response.json()["memberships"]
         memberships = response.json()["memberships"]
@@ -162,7 +178,7 @@ class TestUpdateOrganization:
         self, client: AsyncClient, default_organization: Organization, make_org_user, role
         self, client: AsyncClient, default_organization: Organization, make_org_user, role
     ):
     ):
         _, headers = make_org_user(role=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),
             app.url_path_for("update_organization", organization_id=default_organization_id),
             headers=headers,
             headers=headers,
             json={"name": "Renamed"},
             json={"name": "Renamed"},
@@ -170,10 +186,14 @@ class TestUpdateOrganization:
         assert response.status_code == 403
         assert response.status_code == 403
 
 
     async def test_super_admin_renames(
     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)
         _, 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),
             app.url_path_for("update_organization", organization_id=default_organization_id),
             headers=headers,
             headers=headers,
             json={"name": "Renamed"},
             json={"name": "Renamed"},
@@ -184,7 +204,7 @@ class TestUpdateOrganization:
 
 
     async def test_not_found(self, client: AsyncClient, make_org_user):
     async def test_not_found(self, client: AsyncClient, make_org_user):
         _, 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.patch(
+        response = await client.post(
             app.url_path_for("update_organization", organization_id=uuid.uuid4()),
             app.url_path_for("update_organization", organization_id=uuid.uuid4()),
             headers=headers,
             headers=headers,
             json={"name": "Nope"},
             json={"name": "Nope"},
@@ -199,7 +219,8 @@ class TestDeleteOrganization:
     ):
     ):
         _, headers = make_org_user(role=role)
         _, headers = make_org_user(role=role)
         response = await client.delete(
         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
         assert response.status_code == 403
 
 
@@ -208,12 +229,14 @@ class TestDeleteOrganization:
     ):
     ):
         _, 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.delete(
         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 response.status_code == 200
         assert session.get(Organization, default_organization_id) is None
         assert session.get(Organization, default_organization_id) is None
 
 
         from app.models import Project
         from app.models import Project
+
         assert session.get(Project, default_project.id) is None
         assert session.get(Project, default_project.id) is None
 
 
     async def test_not_found(self, client: AsyncClient, make_org_user):
     async def test_not_found(self, client: AsyncClient, make_org_user):
@@ -223,6 +246,7 @@ class TestDeleteOrganization:
         )
         )
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+
 class TestAddMember:
 class TestAddMember:
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
     @pytest.mark.parametrize("role", ALL_ORG_ROLES + [None])
     async def test_non_super_admin_forbidden(
     async def test_non_super_admin_forbidden(
@@ -237,7 +261,11 @@ class TestAddMember:
         assert response.status_code == 403
         assert response.status_code == 403
 
 
     async def test_super_admin_adds_existing_user(
     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
         target_user, _ = make_org_user(role=None)  # exists, no membership yet
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
@@ -257,7 +285,11 @@ class TestAddMember:
         assert membership.role == OrgRole.RESPO_COMMISSION
         assert membership.role == OrgRole.RESPO_COMMISSION
 
 
     async def test_user_can_belong_to_multiple_orgs(
     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."""
         """Confirms the multi-org membership model -- no unique(user_id) constraint."""
         target_user, _ = make_org_user(role=OrgRole.ORG_ADMIN)  # already in default_organization
         target_user, _ = make_org_user(role=OrgRole.ORG_ADMIN)  # already in default_organization
@@ -273,9 +305,13 @@ class TestAddMember:
         )
         )
         assert response.status_code == 200
         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
         assert len(memberships) == 2
 
 
     async def test_unknown_user_id_not_found(
     async def test_unknown_user_id_not_found(
@@ -313,6 +349,7 @@ class TestAddMember:
         )
         )
         assert response.status_code == 404
         assert response.status_code == 404
 
 
+
 class TestUpdateMemberRole:
 class TestUpdateMemberRole:
     @pytest.mark.parametrize("role", [OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION, None])
     @pytest.mark.parametrize("role", [OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION, None])
     async def test_insufficient_role_forbidden(
     async def test_insufficient_role_forbidden(
@@ -320,9 +357,11 @@ class TestUpdateMemberRole:
     ):
     ):
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, headers = make_org_user(role=role)
         _, headers = make_org_user(role=role)
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
             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,
             headers=headers,
             json={"role": OrgRole.ORG_ADMIN.value},
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -330,14 +369,20 @@ class TestUpdateMemberRole:
         assert response.status_code == 403
         assert response.status_code == 403
 
 
     async def test_org_admin_of_same_org_can_change_role(
     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)
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, org_admin_headers = make_org_user(role=OrgRole.ORG_ADMIN)
         _, org_admin_headers = make_org_user(role=OrgRole.ORG_ADMIN)
 
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
             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,
             headers=org_admin_headers,
             json={"role": OrgRole.RESPO_BENEVOLE.value},
             json={"role": OrgRole.RESPO_BENEVOLE.value},
@@ -349,27 +394,41 @@ class TestUpdateMemberRole:
         assert membership.role == OrgRole.RESPO_BENEVOLE
         assert membership.role == OrgRole.RESPO_BENEVOLE
 
 
     async def test_org_admin_of_different_org_forbidden(
     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."""
         """An org_admin has no authority over an organization they don't belong to."""
         other_org = Organization(id=uuid.uuid4(), name="Other Org")
         other_org = Organization(id=uuid.uuid4(), name="Other Org")
         session.add(other_org)
         session.add(other_org)
         session.commit()
         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
         # 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.add(outsider)
         session.commit()
         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()
         session.commit()
         outsider_token = security.create_jwt_token(str(outsider.id), 60 * 60 * 24, refresh=False)[0]
         outsider_token = security.create_jwt_token(str(outsider.id), 60 * 60 * 24, refresh=False)[0]
         outsider_headers = {"Authorization": f"Bearer {outsider_token}"}
         outsider_headers = {"Authorization": f"Bearer {outsider_token}"}
 
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
             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,
             headers=outsider_headers,
             json={"role": OrgRole.ORG_ADMIN.value},
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -377,14 +436,20 @@ class TestUpdateMemberRole:
         assert response.status_code == 403
         assert response.status_code == 403
 
 
     async def test_super_admin_changes_role(
     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)
         target_user, _ = make_org_user(role=OrgRole.RESPO_COMMISSION)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
 
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
             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,
             headers=admin_headers,
             json={"role": OrgRole.ORG_ADMIN.value},
             json={"role": OrgRole.ORG_ADMIN.value},
@@ -401,7 +466,7 @@ class TestUpdateMemberRole:
         non_member, _ = make_org_user(role=None)
         non_member, _ = make_org_user(role=None)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
 
 
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for(
             app.url_path_for(
                 "update_member_role", organization_id=default_organization_id, user_id=non_member.id
                 "update_member_role", organization_id=default_organization_id, user_id=non_member.id
             ),
             ),
@@ -427,7 +492,11 @@ class TestRemoveMember:
         assert response.status_code == 403
         assert response.status_code == 403
 
 
     async def test_super_admin_removes_member(
     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)
         target_user, _ = make_org_user(role=OrgRole.RESPO_BENEVOLE)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
         _, admin_headers = make_org_user(role=None, global_role=GlobalRole.SUPER_ADMIN)
@@ -455,4 +524,4 @@ class TestRemoveMember:
             ),
             ),
             headers=admin_headers,
             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.main import app
 from app.models import (
 from app.models import (
     Commission,
     Commission,
-    CommissionMember,
     OrgRole,
     OrgRole,
     Project,
     Project,
     Slot,
     Slot,
@@ -33,7 +32,7 @@ def commission_and_member(session: Session, default_public_project: Project, mak
     session.add(commission)
     session.add(commission)
     session.commit()
     session.commit()
     user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
     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()
     session.commit()
     return commission, user, headers
     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.main import app
 from app.models import (
 from app.models import (
     Commission,
     Commission,
-    CommissionMember,
     OrgRole,
     OrgRole,
     Project,
     Project,
     Slot,
     Slot,
@@ -464,12 +463,8 @@ class TestSmsPlaceholders:
         )
         )
         session.add_all([member1, member2])
         session.add_all([member1, member2])
         session.commit()
         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()
         session.commit()
         slot_template = SlotTemplate(
         slot_template = SlotTemplate(
             project_id=default_public_project.id,
             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 sqlalchemy.orm import Session
 
 
 from app.main import app
 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 (
 from app.tests.conftest import (
     default_project_id,
     default_project_id,
     default_slot_id,
     default_slot_id,
@@ -27,7 +27,7 @@ def commission_and_member(session: Session, default_public_project: Project, mak
     session.add(commission)
     session.add(commission)
     session.commit()
     session.commit()
     user, headers = make_org_user(role=OrgRole.RESPO_COMMISSION)
     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()
     session.commit()
     return commission, user, headers
     return commission, user, headers
 
 

+ 5 - 5
app/tests/test_users.py

@@ -110,7 +110,7 @@ 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(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"), json={"name": "New Name"}
             app.url_path_for("update_current_user_profile"), json={"name": "New Name"}
         )
         )
         assert response.status_code == 401
         assert response.status_code == 401
@@ -118,7 +118,7 @@ class TestUpdateCurrentUserProfile:
     async def test_updates_name(
     async def test_updates_name(
         self, client: AsyncClient, default_user_headers: dict, session: Session
         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"),
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             headers=default_user_headers,
             json={"name": "Geralt of Rivia"},
             json={"name": "Geralt of Rivia"},
@@ -127,7 +127,7 @@ class TestUpdateCurrentUserProfile:
         assert response.json()["name"] == "Geralt of Rivia"
         assert response.json()["name"] == "Geralt of Rivia"
 
 
     async def test_updates_phone_number(self, client: AsyncClient, default_user_headers: dict):
     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"),
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             headers=default_user_headers,
             json={"phone_number": "0601020304"},
             json={"phone_number": "0601020304"},
@@ -138,12 +138,12 @@ class TestUpdateCurrentUserProfile:
     async def test_partial_update_does_not_clear_other_field(
     async def test_partial_update_does_not_clear_other_field(
         self, client: AsyncClient, default_user_headers: dict
         self, client: AsyncClient, default_user_headers: dict
     ):
     ):
-        await client.patch(
+        await client.post(
             app.url_path_for("update_current_user_profile"),
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             headers=default_user_headers,
             json={"name": "Geralt", "phone_number": "0601020304"},
             json={"name": "Geralt", "phone_number": "0601020304"},
         )
         )
-        response = await client.patch(
+        response = await client.post(
             app.url_path_for("update_current_user_profile"),
             app.url_path_for("update_current_user_profile"),
             headers=default_user_headers,
             headers=default_user_headers,
             json={"name": "Geralt Updated"},
             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),
     ("GET", "list_project_groups", route_kwargs, None),
     ("POST", "create_group", route_kwargs, {"name": "Test"}),
     ("POST", "create_group", route_kwargs, {"name": "Test"}),
     ("GET", "get_group", route_kwargs_2, None),
     ("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),
     ("DELETE", "delete_group", route_kwargs_2, None),
     ("POST", "add_volunteers_to_group", route_kwargs_2, {"volunteer_ids": []}),
     ("POST", "add_volunteers_to_group", route_kwargs_2, {"volunteer_ids": []}),
     ("DELETE", "remove_volunteer_from_group", {**route_kwargs_2, "volunteer_id": "VOL"}, None),
     ("DELETE", "remove_volunteer_from_group", {**route_kwargs_2, "volunteer_id": "VOL"}, None),
@@ -208,7 +208,7 @@ class TestUpdateGroup:
         session: Session,
         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.post(
             app.url_path_for(
             app.url_path_for(
                 "update_group", project_id=default_project.id, group_id=default_group.id
                 "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.
 # 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]]
 [[package]]
 name = "alembic"
 name = "alembic"
 version = "1.16.5"
 version = "1.16.5"
@@ -2222,4 +2238,4 @@ files = [
 [metadata]
 [metadata]
 lock-version = "2.1"
 lock-version = "2.1"
 python-versions = "^3.11"
 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"
 psycopg2 = "^2.9.9"
 ruff = "^0.16.0"
 ruff = "^0.16.0"
 bcrypt = "4.0.1"
 bcrypt = "4.0.1"
+aiosmtplib = "^5.1.2"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]
 coverage = "^7.1.0"
 coverage = "^7.1.0"