From a0ab79ffe00efcfbe1d215ae907574bbbd57c411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Tue, 30 Jun 2026 17:36:41 +0200 Subject: [PATCH 01/13] feat: support document for memberships --- app/core/documents/utils_documents.py | 10 + app/core/memberships/cruds_memberships.py | 180 +++++-------- app/core/memberships/endpoints_memberships.py | 202 +++++++++++--- app/core/memberships/factory_memberships.py | 1 + app/core/memberships/models_memberships.py | 35 ++- app/core/memberships/schemas_memberships.py | 35 ++- app/core/memberships/utils_memberships.py | 251 +++++++++++++++++- app/core/users/cruds_users.py | 27 ++ app/core/users/utils_users.py | 44 +++ app/modules/cdr/endpoints_cdr.py | 38 ++- app/modules/cdr/exception_cdr.py | 19 ++ migrations/versions/60-membership_document.py | 95 +++++++ tests/core/test_user_fusion.py | 40 ++- 13 files changed, 794 insertions(+), 183 deletions(-) create mode 100644 app/core/users/utils_users.py create mode 100644 app/modules/cdr/exception_cdr.py create mode 100644 migrations/versions/60-membership_document.py diff --git a/app/core/documents/utils_documents.py b/app/core/documents/utils_documents.py index cb776c3098..c2613ed895 100644 --- a/app/core/documents/utils_documents.py +++ b/app/core/documents/utils_documents.py @@ -245,6 +245,16 @@ async def use_template_for_user( ) +async def delete_document( + document: schemas_documents.Document, + documenso: DocumensoAPIWrapper, + db: AsyncSession, +) -> None: + await documenso.delete_document(document_id=int(document.documenso_id)) + + await cruds_documents.delete_document_by_id(document_id=document.id, db=db) + + async def handle_document_callback( document_module: str, document_id: uuid.UUID, diff --git a/app/core/memberships/cruds_memberships.py b/app/core/memberships/cruds_memberships.py index 6557200562..13e1737220 100644 --- a/app/core/memberships/cruds_memberships.py +++ b/app/core/memberships/cruds_memberships.py @@ -1,30 +1,28 @@ -from collections.abc import Sequence from datetime import date from uuid import UUID from sqlalchemy import and_, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.core.documents.types_documenso import DocumentStatus from app.core.memberships import models_memberships, schemas_memberships -from app.core.users import schemas_users +from app.core.memberships.utils_memberships import ( + membership_complete_model_to_schema, + membership_model_to_schema, + user_membership_complete_model_to_schema, + user_membership_with_association_model_to_schema, +) async def get_association_memberships( db: AsyncSession, -) -> Sequence[schemas_memberships.MembershipSimple]: +) -> list[schemas_memberships.MembershipSimple]: result = ( (await db.execute(select(models_memberships.CoreAssociationMembership))) .scalars() .all() ) - return [ - schemas_memberships.MembershipSimple( - name=membership.name, - id=membership.id, - manager_group_id=membership.manager_group_id, - ) - for membership in result - ] + return [membership_model_to_schema(membership) for membership in result] async def get_association_membership_by_name( @@ -42,21 +40,13 @@ async def get_association_membership_by_name( .scalars() .first() ) - return ( - schemas_memberships.MembershipSimple( - name=result.name, - manager_group_id=result.manager_group_id, - id=result.id, - ) - if result - else None - ) + return membership_model_to_schema(result) if result else None async def get_association_membership_by_id( db: AsyncSession, membership_id: UUID, -) -> schemas_memberships.MembershipSimple | None: +) -> schemas_memberships.MembershipComplete | None: result = ( ( await db.execute( @@ -68,15 +58,7 @@ async def get_association_membership_by_id( .scalars() .first() ) - return ( - schemas_memberships.MembershipSimple( - name=result.name, - manager_group_id=result.manager_group_id, - id=result.id, - ) - if result - else None - ) + return membership_complete_model_to_schema(result) if result else None async def create_association_membership( @@ -87,6 +69,7 @@ async def create_association_membership( id=membership.id, name=membership.name, manager_group_id=membership.manager_group_id, + template_id=membership.template_id, ) db.add(membership_db) @@ -105,14 +88,13 @@ async def delete_association_membership( async def update_association_membership( db: AsyncSession, membership_id: UUID, - membership: schemas_memberships.MembershipBase, + membership: schemas_memberships.MembershipEdit, ): await db.execute( update(models_memberships.CoreAssociationMembership) .where(models_memberships.CoreAssociationMembership.id == membership_id) .values( - name=membership.name, - manager_group_id=membership.manager_group_id, + **membership.model_dump(exclude_unset=True), ), ) @@ -125,7 +107,7 @@ async def get_user_memberships_by_user_id( minimal_end_date: date | None = None, maximal_end_date: date | None = None, manager_restriction: list[str] | None = None, -) -> Sequence[schemas_memberships.UserMembershipComplete]: +) -> list[schemas_memberships.UserMembershipComplete]: result = ( ( await db.execute( @@ -161,22 +143,7 @@ async def get_user_memberships_by_user_id( .all() ) return [ - schemas_memberships.UserMembershipComplete( - id=membership.id, - user_id=membership.user_id, - association_membership_id=membership.association_membership_id, - start_date=membership.start_date, - end_date=membership.end_date, - user=schemas_users.CoreUserSimple( - id=membership.user.id, - account_type=membership.user.account_type, - school_id=membership.user.school_id, - nickname=membership.user.nickname, - firstname=membership.user.firstname, - name=membership.user.name, - ), - ) - for membership in result + user_membership_complete_model_to_schema(membership) for membership in result ] @@ -187,7 +154,7 @@ async def get_user_memberships_by_association_membership_id( maximal_start_date: date | None = None, minimal_end_date: date | None = None, maximal_end_date: date | None = None, -) -> Sequence[schemas_memberships.UserMembershipComplete]: +) -> list[schemas_memberships.UserMembershipComplete]: result = ( ( await db.execute( @@ -217,22 +184,7 @@ async def get_user_memberships_by_association_membership_id( .all() ) return [ - schemas_memberships.UserMembershipComplete( - id=membership.id, - user_id=membership.user_id, - association_membership_id=membership.association_membership_id, - start_date=membership.start_date, - end_date=membership.end_date, - user=schemas_users.CoreUserSimple( - id=membership.user.id, - account_type=membership.user.account_type, - school_id=membership.user.school_id, - nickname=membership.user.nickname, - firstname=membership.user.firstname, - name=membership.user.name, - ), - ) - for membership in result + user_membership_complete_model_to_schema(membership) for membership in result ] @@ -240,7 +192,7 @@ async def get_user_memberships_by_user_id_and_association_membership_id( db: AsyncSession, user_id: str, association_membership_id: UUID, -) -> Sequence[schemas_memberships.UserMembershipComplete]: +) -> list[schemas_memberships.UserMembershipComplete]: result = ( ( await db.execute( @@ -255,22 +207,7 @@ async def get_user_memberships_by_user_id_and_association_membership_id( .all() ) return [ - schemas_memberships.UserMembershipComplete( - id=membership.id, - user_id=membership.user_id, - association_membership_id=membership.association_membership_id, - start_date=membership.start_date, - end_date=membership.end_date, - user=schemas_users.CoreUserSimple( - id=membership.user.id, - account_type=membership.user.account_type, - school_id=membership.user.school_id, - nickname=membership.user.nickname, - firstname=membership.user.firstname, - name=membership.user.name, - ), - ) - for membership in result + user_membership_complete_model_to_schema(membership) for membership in result ] @@ -280,7 +217,7 @@ async def get_user_memberships_by_user_id_start_end_and_association_membership_i start_date: date, end_date: date, association_membership_id: UUID, -) -> Sequence[schemas_memberships.UserMembershipComplete]: +) -> list[schemas_memberships.UserMembershipComplete]: result = ( ( await db.execute( @@ -299,29 +236,14 @@ async def get_user_memberships_by_user_id_start_end_and_association_membership_i .all() ) return [ - schemas_memberships.UserMembershipComplete( - id=membership.id, - user_id=membership.user_id, - association_membership_id=membership.association_membership_id, - start_date=membership.start_date, - end_date=membership.end_date, - user=schemas_users.CoreUserSimple( - id=membership.user.id, - account_type=membership.user.account_type, - school_id=membership.user.school_id, - nickname=membership.user.nickname, - firstname=membership.user.firstname, - name=membership.user.name, - ), - ) - for membership in result + user_membership_complete_model_to_schema(membership) for membership in result ] async def get_user_membership_by_id( db: AsyncSession, user_membership_id: UUID, -) -> schemas_memberships.UserMembershipComplete | None: +) -> schemas_memberships.UserMembershipWithAssociation | None: result = ( ( await db.execute( @@ -334,25 +256,26 @@ async def get_user_membership_by_id( .scalars() .first() ) - return ( - schemas_memberships.UserMembershipComplete( - id=result.id, - user_id=result.user_id, - association_membership_id=result.association_membership_id, - start_date=result.start_date, - end_date=result.end_date, - user=schemas_users.CoreUserSimple( - id=result.user.id, - account_type=result.user.account_type, - school_id=result.user.school_id, - nickname=result.user.nickname, - firstname=result.user.firstname, - name=result.user.name, - ), + return user_membership_with_association_model_to_schema(result) if result else None + + +async def get_user_membership_by_document_id( + db: AsyncSession, + document_id: UUID, +) -> schemas_memberships.UserMembershipWithAssociation | None: + result = ( + ( + await db.execute( + select(models_memberships.CoreAssociationUserMembership).where( + models_memberships.CoreAssociationUserMembership.document_id + == document_id, + ), + ) ) - if result - else None + .scalars() + .first() ) + return user_membership_with_association_model_to_schema(result) if result else None async def create_user_membership( @@ -365,6 +288,8 @@ async def create_user_membership( association_membership_id=user_membership.association_membership_id, start_date=user_membership.start_date, end_date=user_membership.end_date, + document_id=user_membership.document_id, + document_status=user_membership.document_status, ) db.add(membership_db) @@ -390,5 +315,20 @@ async def update_user_membership( .where( models_memberships.CoreAssociationUserMembership.id == user_membership_id, ) - .values(**user_membership_edit.model_dump(exclude_none=True)), + .values(**user_membership_edit.model_dump(exclude_unset=True)), + ) + + +async def update_user_membership_document( + db: AsyncSession, + user_membership_id: UUID, + document_id: UUID | None, + document_status: DocumentStatus | None, +): + await db.execute( + update(models_memberships.CoreAssociationUserMembership) + .where( + models_memberships.CoreAssociationUserMembership.id == user_membership_id, + ) + .values(document_id=document_id, document_status=document_status), ) diff --git a/app/core/memberships/endpoints_memberships.py b/app/core/memberships/endpoints_memberships.py index 6daf6675cf..e9ab8ad73c 100644 --- a/app/core/memberships/endpoints_memberships.py +++ b/app/core/memberships/endpoints_memberships.py @@ -1,3 +1,4 @@ +import asyncio import logging import uuid from datetime import UTC, date, datetime @@ -5,6 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession +from app.core.documents import cruds_documents +from app.core.documents.exceptions_documents import ( + DocumentCreationError, + ElementTeamNotFoundError, +) from app.core.groups import cruds_groups, models_groups from app.core.groups.groups_type import GroupType from app.core.memberships import ( @@ -12,10 +18,17 @@ schemas_memberships, ) from app.core.memberships.factory_memberships import CoreMembershipsFactory -from app.core.memberships.utils_memberships import validate_user_new_membership -from app.core.users import cruds_users, models_users, schemas_users +from app.core.memberships.utils_memberships import ( + MODULE_ROOT, + add_membership_to_user, + renew_membership_documents, + validate_user_new_membership, +) +from app.core.users import cruds_users, models_users +from app.core.users.utils_users import user_model_to_schema from app.dependencies import ( get_db, + get_settings, is_user, is_user_in, ) @@ -27,7 +40,7 @@ hyperion_error_logger = logging.getLogger("hyperion.error") core_module = CoreModule( - root="memberships", + root=MODULE_ROOT, tag="Memberships", router=router, factory=CoreMembershipsFactory(), @@ -50,6 +63,44 @@ async def read_associations_memberships( return await cruds_memberships.get_association_memberships(db) +@router.get( + "/memberships/{association_membership_id}", + response_model=schemas_memberships.MembershipComplete, + status_code=200, +) +async def read_association_membership_by_id( + association_membership_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Return membership with the given ID. + + **This endpoint is only usable by the membership owner** + """ + db_association_membership = ( + await cruds_memberships.get_association_membership_by_id( + db=db, + membership_id=association_membership_id, + ) + ) + if db_association_membership is None: + raise HTTPException(status_code=404, detail="Association Membership not found") + + if not is_user_member_of_any_group( + user, + [ + db_association_membership.manager_group_id, + GroupType.admin, + ], + ): + raise HTTPException( + status_code=403, + detail="User is not allowed to access this membership", + ) + return db_association_membership + + @router.get( "/memberships/{association_membership_id}/members", response_model=list[schemas_memberships.UserMembershipComplete], @@ -142,7 +193,6 @@ async def create_association_membership( db=db, membership=db_association_membership, ) - await db.flush() return db_association_membership @@ -152,7 +202,7 @@ async def create_association_membership( ) async def update_association_membership( association_membership_id: uuid.UUID, - membership: schemas_memberships.MembershipBase, + membership: schemas_memberships.MembershipEdit, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user_in(GroupType.admin)), ): @@ -176,7 +226,83 @@ async def update_association_membership( membership=membership, ) - await db.flush() + +@router.post( + "/memberships/{membership_id}/renew-documents", + status_code=201, + response_model=schemas_memberships.MembershipRenewalErrors, +) +async def renew_users_membership_document( + renewal_criterion: schemas_memberships.MembershipRenewalCriterion, + membership_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), + settings=Depends(get_settings), +): + """ + Renew the documents of the memberships of the given users for a specific association membership. + + **This endpoint is only usable by administrators and membership managers** + """ + db_association_membership = ( + await cruds_memberships.get_association_membership_by_id( + db=db, + membership_id=membership_id, + ) + ) + if db_association_membership is None: + raise HTTPException(status_code=404, detail="Association Membership not found") + + if not is_user_member_of_any_group( + user, + [ + GroupType.admin, + db_association_membership.manager_group_id, + ], + ): + raise HTTPException(status_code=403, detail="Unauthorized") + + if db_association_membership.template is None: + raise HTTPException( + status_code=400, + detail="Association Membership has no template associated", + ) + team = await cruds_documents.get_team_by_id( + db=db, + team_id=db_association_membership.template.team_id, + ) + if team is None: + raise ElementTeamNotFoundError( + team_id=db_association_membership.template.team_id, + ) + + renewal_targets = ( + await cruds_memberships.get_user_memberships_by_association_membership_id( + db=db, + association_membership_id=membership_id, + maximal_start_date=renewal_criterion.active_date, + minimal_end_date=renewal_criterion.active_date, + ) + ) + + results: list[None | BaseException] = await asyncio.gather( + *[ + renew_membership_documents( + association_membership=db_association_membership, + team=team, + user_membership=user_membership, + db=db, + settings=settings, + ) + for user_membership in renewal_targets + ], + return_exceptions=True, + ) + errors: dict[str, str] = {} + for res in results: + if isinstance(res, DocumentCreationError): + errors[res.user_email] = res.message + return schemas_memberships.MembershipRenewalErrors(errors=errors) @router.delete( @@ -298,6 +424,7 @@ async def create_user_membership( user_membership: schemas_memberships.UserMembershipBase, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user()), + settings=Depends(get_settings), ): """ Create a new user membership. @@ -335,26 +462,14 @@ async def create_user_membership( association_membership_id=user_membership.association_membership_id, start_date=user_membership.start_date, end_date=user_membership.end_date, + valid=True, ) - await validate_user_new_membership(db_user_membership, db) - - await cruds_memberships.create_user_membership( - db=db, + return await add_membership_to_user( + user=user_model_to_schema(db_user), + association_membership=db_association_membership, user_membership=db_user_membership, - ) - - await db.flush() - - return schemas_memberships.UserMembershipComplete( - **db_user_membership.__dict__, - user=schemas_users.CoreUserSimple( - name=db_user.name, - id=db_user.id, - firstname=db_user.firstname, - nickname=db_user.nickname, - account_type=db_user.account_type, - school_id=db_user.school_id, - ), + db=db, + settings=settings, ) @@ -368,6 +483,7 @@ async def add_batch_membership( memberships_details: list[schemas_memberships.MembershipUserMappingEmail], db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user()), + settings=Depends(get_settings), ): """ Add a batch of user to a membership. @@ -414,16 +530,23 @@ async def add_batch_membership( end_date=detail.end_date, ) if len(stored_memberships) == 0: - await cruds_memberships.create_user_membership( - db=db, - user_membership=schemas_memberships.UserMembershipSimple( - id=uuid.uuid4(), - user_id=detail_user.id, - association_membership_id=association_membership_id, - start_date=detail.start_date, - end_date=detail.end_date, - ), - ) + try: + await add_membership_to_user( + user=user_model_to_schema(detail_user), + association_membership=db_association_membership, + user_membership=schemas_memberships.UserMembershipSimple( + id=uuid.uuid4(), + user_id=detail_user.id, + association_membership_id=association_membership_id, + start_date=detail.start_date, + end_date=detail.end_date, + valid=True, + ), + db=db, + settings=settings, + ) + except HTTPException: + pass await db.flush() return unknown_users @@ -450,16 +573,10 @@ async def update_user_membership( ) if db_user_membership is None: raise HTTPException(status_code=404, detail="User membership not found") - db_membership = await cruds_memberships.get_association_membership_by_id( - db, - db_user_membership.association_membership_id, - ) - if db_membership is None: - raise ValueError if not is_user_member_of_any_group( user, - [GroupType.admin, db_membership.manager_group_id], + [GroupType.admin, db_user_membership.association_membership.manager_group_id], ): raise HTTPException(status_code=403, detail="Unauthorized") @@ -469,6 +586,7 @@ async def update_user_membership( association_membership_id=db_user_membership.association_membership_id, start_date=user_membership.start_date or db_user_membership.start_date, end_date=user_membership.end_date or db_user_membership.end_date, + valid=db_user_membership.valid, ) await validate_user_new_membership(new_membership, db) @@ -479,8 +597,6 @@ async def update_user_membership( user_membership_edit=user_membership, ) - await db.flush() - @router.delete( "/memberships/users/{membership_id}", diff --git a/app/core/memberships/factory_memberships.py b/app/core/memberships/factory_memberships.py index df760251ea..0afce827de 100644 --- a/app/core/memberships/factory_memberships.py +++ b/app/core/memberships/factory_memberships.py @@ -66,6 +66,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: random.randint(1, 28), # noqa: S311 tzinfo=datetime.UTC, ), + valid=True, ), ) await db.commit() diff --git a/app/core/memberships/models_memberships.py b/app/core/memberships/models_memberships.py index 1ab9a92adf..6f71e6b6a8 100644 --- a/app/core/memberships/models_memberships.py +++ b/app/core/memberships/models_memberships.py @@ -4,6 +4,8 @@ from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.documents.models_documents import DocumentDocument, DocumentTemplate +from app.core.documents.types_documenso import DocumentStatus from app.core.users.models_users import CoreUser from app.types.sqlalchemy import Base, PrimaryKey @@ -14,7 +16,16 @@ class CoreAssociationMembership(Base): id: Mapped[PrimaryKey] name: Mapped[str] = mapped_column(unique=True) manager_group_id: Mapped[str] = mapped_column(ForeignKey("core_group.id")) - # Unused for now + template_id: Mapped[UUID | None] = mapped_column( + ForeignKey("document_template.id"), + default=None, + ) + + template: Mapped[DocumentTemplate | None] = relationship( + "DocumentTemplate", + lazy="joined", + init=False, + ) class CoreAssociationUserMembership(Base): @@ -30,9 +41,31 @@ class CoreAssociationUserMembership(Base): ) start_date: Mapped[date] end_date: Mapped[date] + document_id: Mapped[UUID | None] = mapped_column( + ForeignKey("document_document.id"), + default=None, + ) + document_status: Mapped[DocumentStatus | None] = mapped_column(default=None) + + @property + def valid(self) -> bool: + """Check if the membership is currently valid based document_status""" + return ( + self.document_id is None or self.document_status == DocumentStatus.COMPLETED + ) user: Mapped[CoreUser] = relationship( "CoreUser", lazy="joined", init=False, ) + association_membership: Mapped[CoreAssociationMembership] = relationship( + "CoreAssociationMembership", + lazy="joined", + init=False, + ) + document: Mapped[DocumentDocument | None] = relationship( + "DocumentDocument", + lazy="joined", + init=False, + ) diff --git a/app/core/memberships/schemas_memberships.py b/app/core/memberships/schemas_memberships.py index 0bb8056ffc..04611f6e76 100644 --- a/app/core/memberships/schemas_memberships.py +++ b/app/core/memberships/schemas_memberships.py @@ -3,12 +3,14 @@ from pydantic import BaseModel, ConfigDict +from app.core.documents import schemas_documents from app.core.users import schemas_users class MembershipBase(BaseModel): name: str manager_group_id: str + template_id: UUID | None = None class MembershipSimple(MembershipBase): @@ -17,10 +19,30 @@ class MembershipSimple(MembershipBase): model_config = ConfigDict(from_attributes=True) +class MembershipComplete(MembershipSimple): + template: schemas_documents.Template | None = None + + model_config = ConfigDict(from_attributes=True) + + +class MembershipEdit(BaseModel): + name: str | None = None + manager_group_id: str | None = None + template_id: UUID | None = None + + class MembershipDateFilter(BaseModel): minimal_date: date | None = None +class MembershipRenewalCriterion(BaseModel): + active_date: date + + +class MembershipRenewalErrors(BaseModel): + errors: dict[str, str] + + class UserMembershipBase(BaseModel): association_membership_id: UUID start_date: date @@ -30,12 +52,22 @@ class UserMembershipBase(BaseModel): class UserMembershipSimple(UserMembershipBase): id: UUID user_id: str + document_id: UUID | None = None + document_status: schemas_documents.DocumentStatus | None = None + valid: bool model_config = ConfigDict(from_attributes=True) class UserMembershipComplete(UserMembershipSimple): - user: schemas_users.CoreUserSimple + user: schemas_users.CoreUser + document: schemas_documents.Document | None = None + + model_config = ConfigDict(from_attributes=True) + + +class UserMembershipWithAssociation(UserMembershipComplete): + association_membership: MembershipSimple model_config = ConfigDict(from_attributes=True) @@ -43,6 +75,7 @@ class UserMembershipComplete(UserMembershipSimple): class UserMembershipEdit(BaseModel): start_date: date | None = None end_date: date | None = None + document_status: schemas_documents.DocumentStatus | None = None class MembershipUserMappingEmail(BaseModel): diff --git a/app/core/memberships/utils_memberships.py b/app/core/memberships/utils_memberships.py index 6d80cfa31a..3d41e8b42d 100644 --- a/app/core/memberships/utils_memberships.py +++ b/app/core/memberships/utils_memberships.py @@ -4,7 +4,90 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.memberships import cruds_memberships, schemas_memberships +from app.core.documents import cruds_documents, schemas_documents +from app.core.documents.exceptions_documents import ( + ElementTemplateNotFoundError, +) +from app.core.documents.types_documenso import DocumentStatus +from app.core.documents.utils_documents import ( + _configure_documenso_api_wrapper, + delete_document, + document_model_to_schema, + template_model_to_schema, + use_template_for_user, +) +from app.core.memberships import ( + cruds_memberships, + models_memberships, + schemas_memberships, +) +from app.core.users.schemas_users import CoreUser +from app.core.users.utils_users import user_model_to_schema +from app.core.utils.config import Settings + +MODULE_ROOT = "memberships" + + +def membership_model_to_schema( + model: models_memberships.CoreAssociationMembership, +) -> schemas_memberships.MembershipSimple: + """Convert a CoreAssociationMembership model to a MembershipSimple schema.""" + return schemas_memberships.MembershipSimple( + id=model.id, + name=model.name, + manager_group_id=model.manager_group_id, + template_id=model.template_id, + ) + + +def membership_complete_model_to_schema( + model: models_memberships.CoreAssociationMembership, +) -> schemas_memberships.MembershipComplete: + """Convert a CoreAssociationMembership model to a MembershipComplete schema.""" + return schemas_memberships.MembershipComplete( + id=model.id, + name=model.name, + manager_group_id=model.manager_group_id, + template_id=model.template_id, + template=template_model_to_schema(model.template) if model.template else None, + ) + + +def user_membership_complete_model_to_schema( + model: models_memberships.CoreAssociationUserMembership, +) -> schemas_memberships.UserMembershipComplete: + """Convert a CoreAssociationUserMembership model to a UserMembershipComplete schema.""" + return schemas_memberships.UserMembershipComplete( + id=model.id, + user_id=model.user_id, + association_membership_id=model.association_membership_id, + start_date=model.start_date, + end_date=model.end_date, + document_id=model.document_id, + document_status=model.document_status, + valid=model.valid, + user=user_model_to_schema(model.user), + document=document_model_to_schema(model.document) if model.document else None, + ) + + +def user_membership_with_association_model_to_schema( + model: models_memberships.CoreAssociationUserMembership, +) -> schemas_memberships.UserMembershipWithAssociation: + """Convert a CoreAssociationUserMembership model to a UserMembershipComplete schema.""" + return schemas_memberships.UserMembershipWithAssociation( + id=model.id, + user_id=model.user_id, + association_membership_id=model.association_membership_id, + start_date=model.start_date, + end_date=model.end_date, + document_id=model.document_id, + document_status=model.document_status, + valid=model.valid, + user=user_model_to_schema(model.user), + document=document_model_to_schema(model.document) if model.document else None, + association_membership=membership_model_to_schema(model.association_membership), + ) async def validate_user_new_membership( @@ -75,3 +158,169 @@ async def get_user_active_membership_to_association_membership( return membership return None + + +async def membership_document_callback( + document_id: UUID, + document_status: DocumentStatus, + db: AsyncSession, +): + """ + Handle the callback from the document service for a membership document. + :param document_id: The ID of the document that was updated. + :param document_status: The new status of the document. + :param db: The database session. + """ + user_membership = await cruds_memberships.get_user_membership_by_document_id( + db, + document_id, + ) + if user_membership is None: + raise HTTPException( + status_code=404, + detail="User membership not found for the given document ID.", + ) + + await cruds_memberships.update_user_membership( + db, + user_membership.id, + schemas_memberships.UserMembershipEdit(document_status=document_status), + ) + + +async def add_membership_to_user( + user: CoreUser, + association_membership: schemas_memberships.MembershipSimple, + user_membership: schemas_memberships.UserMembershipSimple, + db: AsyncSession, + settings: Settings, +): + """ + Add a membership to a user. + :param user: The user to add the membership to. + :param association_membership: The association membership to add. + :param user_membership: The user membership to add. + :param db: The database session. + """ + await validate_user_new_membership(user_membership, db) + + if association_membership.template_id is not None: + template = await cruds_documents.get_template_by_id( + db, + association_membership.template_id, + ) + if template is None: + raise ElementTemplateNotFoundError(association_membership.template_id) + + documenso = _configure_documenso_api_wrapper( + team=template.team, + settings=settings, + ) + + document = await use_template_for_user( + user=user, + template=template, + module=MODULE_ROOT, + db=db, + documenso=documenso, + ) + user_membership.document_id = document.id + user_membership.document_status = document.status + user_membership.valid = ( + user_membership.document_status == DocumentStatus.COMPLETED + ) + + await cruds_memberships.create_user_membership( + db=db, + user_membership=user_membership, + ) + + return schemas_memberships.UserMembershipComplete( + id=user_membership.id, + user_id=user_membership.user_id, + association_membership_id=user_membership.association_membership_id, + start_date=user_membership.start_date, + end_date=user_membership.end_date, + document_id=user_membership.document_id, + document_status=user_membership.document_status, + valid=user_membership.valid, + user=user, + ) + + +async def renew_membership_documents( + association_membership: schemas_memberships.MembershipComplete, + team: schemas_documents.Team, + user_membership: schemas_memberships.UserMembershipComplete, + db: AsyncSession, + settings: Settings, +) -> None: + """ + Renew the documents for a user's membership to an association membership. + :param association_membership: The association membership to renew. + :param user: The user to renew the membership for. + :param db: The database session. + :param settings: The application settings. + """ + + if association_membership.template is None: + return + + documenso = _configure_documenso_api_wrapper( + team=team, + settings=settings, + ) + + document = await use_template_for_user( + user=user_membership.user, + template=association_membership.template, + module=MODULE_ROOT, + db=db, + documenso=documenso, + ) + + await cruds_memberships.update_user_membership_document( + db=db, + user_membership_id=user_membership.id, + document_id=document.id, + document_status=document.status, + ) + + +async def remove_membership_from_user( + user_membership: schemas_memberships.UserMembershipComplete, + settings: Settings, + db: AsyncSession, +): + """ + Remove a membership from a user. + :param user_membership: The user membership to remove. + :param db: The database session. + """ + await cruds_memberships.delete_user_membership( + db=db, + user_membership_id=user_membership.id, + ) + + if user_membership.document_id is not None: + document = await cruds_documents.get_document_by_id( + db, + user_membership.document_id, + ) + if document is None: + return + template = await cruds_documents.get_template_by_id( + db, + document.template_id, + ) + if template is None: + return + documenso = _configure_documenso_api_wrapper( + team=template.team, + settings=settings, + ) + await delete_document( + document=document, + documenso=documenso, + db=db, + ) diff --git a/app/core/users/cruds_users.py b/app/core/users/cruds_users.py index 7cdf437545..0e8bcf4862 100644 --- a/app/core/users/cruds_users.py +++ b/app/core/users/cruds_users.py @@ -139,6 +139,33 @@ async def get_user_by_email( return result.scalars().first() +async def get_users_by_ids( + db: AsyncSession, + user_ids: list[str], +) -> list[schemas_users.CoreUser]: + """Return users with ids from database as a list of dictionaries""" + + result = await db.execute( + select(models_users.CoreUser).where(models_users.CoreUser.id.in_(user_ids)), + ) + return [ + schemas_users.CoreUser( + id=user.id, + name=user.name, + firstname=user.firstname, + email=user.email, + account_type=user.account_type, + school_id=user.school_id, + birthday=user.birthday, + phone=user.phone, + promo=user.promo, + floor=user.floor, + created_on=user.created_on, + ) + for user in result.scalars().all() + ] + + async def get_users_by_emails( db: AsyncSession, emails: list[str], diff --git a/app/core/users/utils_users.py b/app/core/users/utils_users.py new file mode 100644 index 0000000000..b135cde862 --- /dev/null +++ b/app/core/users/utils_users.py @@ -0,0 +1,44 @@ +from app.core.users import models_users, schemas_users + + +def user_simple_model_to_schema( + model: models_users.CoreUser, +) -> schemas_users.CoreUserSimple: + """Convert a CoreUser model to a CoreUserSimple schema.""" + return schemas_users.CoreUserSimple( + id=model.id, + account_type=model.account_type, + school_id=model.school_id, + nickname=model.nickname, + firstname=model.firstname, + name=model.name, + ) + + +def user_model_to_schema( + model: models_users.CoreUser, +) -> schemas_users.CoreUser: + """Convert a CoreUser model to a CoreUser schema.""" + return schemas_users.CoreUser( + id=model.id, + account_type=model.account_type, + school_id=model.school_id, + nickname=model.nickname, + firstname=model.firstname, + name=model.name, + email=model.email, + birthday=model.birthday, + promo=model.promo, + floor=model.floor, + phone=model.phone, + created_on=model.created_on, + groups=[ + schemas_users.CoreGroupSimple(id=group.id, name=group.name) + for group in model.groups + ], + school=schemas_users.CoreSchool( + id=model.school.id, + name=model.school.name, + email_regex=model.school.email_regex, + ), + ) diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index d4af676047..e1674c7bca 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -18,11 +18,16 @@ from app.core.groups import cruds_groups, schemas_groups from app.core.groups.groups_type import AccountType from app.core.memberships import cruds_memberships, schemas_memberships +from app.core.memberships.utils_memberships import ( + add_membership_to_user, + remove_membership_from_user, +) from app.core.payment.payment_tool import PaymentTool from app.core.payment.types_payment import HelloAssoConfigName from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, models_users, schemas_users from app.core.users.cruds_users import get_user_by_id, get_users +from app.core.users.utils_users import user_model_to_schema from app.core.utils.config import Settings from app.dependencies import ( get_db, @@ -35,6 +40,10 @@ ) from app.modules.cdr import coredata_cdr, cruds_cdr, models_cdr, schemas_cdr from app.modules.cdr.dependencies_cdr import get_current_cdr_year +from app.modules.cdr.exception_cdr import ( + ProductAssociationMembershipNotFoundError, + PurchaseUserNotFoundError, +) from app.modules.cdr.types_cdr import ( CdrLogActionType, CdrStatus, @@ -1764,6 +1773,7 @@ async def remove_existing_membership( existing_membership: schemas_memberships.UserMembershipComplete, product_variant: models_cdr.ProductVariant, db: AsyncSession, + settings: Settings, ): if product_variant.related_membership_added_duration: if ( @@ -1771,9 +1781,10 @@ async def remove_existing_membership( - product_variant.related_membership_added_duration <= existing_membership.start_date ): - await cruds_memberships.delete_user_membership( + await remove_membership_from_user( + user_membership=existing_membership, db=db, - user_membership_id=existing_membership.id, + settings=settings, ) else: await cruds_memberships.update_user_membership( @@ -1792,6 +1803,7 @@ async def add_membership( product_related_membership_id: UUID, product_variant: models_cdr.ProductVariant, db: AsyncSession, + settings: Settings, ): if product_variant.related_membership_added_duration: existing_membership = next( @@ -1812,7 +1824,22 @@ async def add_membership( ), ) else: - await cruds_memberships.create_user_membership( + user = await cruds_users.get_user_by_id(db=db, user_id=user_id) + if not user: + raise PurchaseUserNotFoundError(user_id) + association_membership = ( + await cruds_memberships.get_association_membership_by_id( + db=db, + membership_id=product_related_membership_id, + ) + ) + if not association_membership: + raise ProductAssociationMembershipNotFoundError( + product_variant.product_id, + ) + await add_membership_to_user( + user=user_model_to_schema(user), + association_membership=association_membership, db=db, user_membership=schemas_memberships.UserMembershipSimple( id=uuid4(), @@ -1821,7 +1848,9 @@ async def add_membership( start_date=date(datetime.now(tz=UTC).date().year, 9, 1), end_date=date(datetime.now(tz=UTC).date().year, 9, 1) + product_variant.related_membership_added_duration, + valid=True, ), + settings=settings, ) @@ -1837,6 +1866,7 @@ async def mark_purchase_as_validated( user: models_users.CoreUser = Depends( is_user_allowed_to([CdrPermissions.manage_cdr]), ), + settings: Settings = Depends(get_settings), ): """ Validate a purchase. @@ -1918,6 +1948,7 @@ async def mark_purchase_as_validated( product_related_membership_id=product.related_membership.id, product_variant=product_variant, db=db, + settings=settings, ) for ticketgen in product.tickets: ticket = models_cdr.Ticket( @@ -1952,6 +1983,7 @@ async def mark_purchase_as_validated( existing_membership=existing_membership, product_variant=product_variant, db=db, + settings=settings, ) if product.tickets: diff --git a/app/modules/cdr/exception_cdr.py b/app/modules/cdr/exception_cdr.py new file mode 100644 index 0000000000..f33b13553b --- /dev/null +++ b/app/modules/cdr/exception_cdr.py @@ -0,0 +1,19 @@ +from uuid import UUID + + +class PurchaseUserNotFoundError(Exception): + """Raised when a user is not found for a purchase.""" + + def __init__(self, user_id: str): + self.user_id = user_id + super().__init__(f"User with ID {user_id} not found for the purchase.") + + +class ProductAssociationMembershipNotFoundError(Exception): + """Raised when a product is not found for an association membership.""" + + def __init__(self, product_id: UUID): + self.product_id = product_id + super().__init__( + f"Product with ID {product_id} not found for the association membership.", + ) diff --git a/migrations/versions/60-membership_document.py b/migrations/versions/60-membership_document.py new file mode 100644 index 0000000000..1c8d8ce9ef --- /dev/null +++ b/migrations/versions/60-membership_document.py @@ -0,0 +1,95 @@ +"""UnregisteredRecoveryRequests + +Create Date: 2026-05-30 14:03:43.757430 +""" + +from collections.abc import Sequence +from enum import StrEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "21ee852eac58" +down_revision: str | None = "84ee3296cc58" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class DocumentStatus(StrEnum): + DRAFT = "DRAFT" + PENDING = "PENDING" + COMPLETED = "COMPLETED" + REJECTED = "REJECTED" + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + op.add_column( + "core_association_membership", + sa.Column("template_id", sa.Uuid(), nullable=True), + ) + op.create_foreign_key( + "core_association_membership_template_id_fkey", + "core_association_membership", + "document_template", + ["template_id"], + ["id"], + ) + op.add_column( + "core_association_user_membership", + sa.Column("document_id", sa.Uuid(), nullable=True), + ) + op.add_column( + "core_association_user_membership", + sa.Column( + "document_status", + sa.Enum( + DocumentStatus, + name="documentstatus", + ), + nullable=True, + ), + ) + op.create_foreign_key( + "core_association_user_membership_document_id_fkey", + "core_association_user_membership", + "document_document", + ["document_id"], + ["id"], + ) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "core_association_user_membership_document_id_fkey", + "core_association_user_membership", + type_="foreignkey", + ) + op.drop_column("core_association_user_membership", "document_status") + op.drop_column("core_association_user_membership", "document_id") + op.drop_constraint( + "core_association_membership_template_id_fkey", + "core_association_membership", + type_="foreignkey", + ) + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/core/test_user_fusion.py b/tests/core/test_user_fusion.py index fd967e825a..6d0c96f8d0 100644 --- a/tests/core/test_user_fusion.py +++ b/tests/core/test_user_fusion.py @@ -7,9 +7,11 @@ from app.core.groups import models_groups from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships +from app.core.memberships.schemas_memberships import UserMembershipComplete from app.core.mypayment import models_mypayment from app.core.mypayment.types_mypayment import WalletType from app.core.users import models_users +from app.core.users.utils_users import user_model_to_schema from tests.commons import ( add_object_to_db, create_api_access_token, @@ -185,20 +187,30 @@ def test_fusion_users(client: TestClient) -> None: assert response.status_code == 200 memberships = response.json() assert len(memberships) == 2 - user_kept_membership_aeecl_json = { - "id": str(core_association_membership_user_kept.id), - "user_id": str(student_user_to_keep.id), - "association_membership_id": str(core_association_membership.id), - "start_date": core_association_membership_user_kept.start_date.isoformat(), - "end_date": core_association_membership_user_kept.end_date.isoformat(), - } - user_del_membership_aeecl_json = { - "id": str(core_association_membership_user_del.id), - "user_id": str(student_user_to_keep.id), - "association_membership_id": str(core_association_membership.id), - "start_date": core_association_membership_user_del.start_date.isoformat(), - "end_date": core_association_membership_user_del.end_date.isoformat(), - } + user_kept_membership_aeecl_json: dict = UserMembershipComplete( + id=core_association_membership_user_kept.id, + user_id=student_user_to_keep.id, + association_membership_id=core_association_membership.id, + start_date=core_association_membership_user_kept.start_date, + end_date=core_association_membership_user_kept.end_date, + document_id=None, + document_status=None, + valid=core_association_membership_user_kept.valid, + user=user_model_to_schema(student_user_to_keep), + ).model_dump(mode="json") + user_kept_membership_aeecl_json.pop("user") + user_del_membership_aeecl_json: dict = UserMembershipComplete( + id=core_association_membership_user_del.id, + user_id=student_user_to_keep.id, + association_membership_id=core_association_membership.id, + start_date=core_association_membership_user_del.start_date, + end_date=core_association_membership_user_del.end_date, + document_id=None, + document_status=None, + valid=core_association_membership_user_del.valid, + user=user_model_to_schema(student_user_to_keep), + ).model_dump(mode="json") + user_del_membership_aeecl_json.pop("user") simple_memberships = [] for membership in memberships: membership.pop("user") From 232c8de88fbaf70b3045fc3f227bc3a0db3c96b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Tue, 30 Jun 2026 17:50:44 +0200 Subject: [PATCH 02/13] fix: typing --- app/core/memberships/schemas_memberships.py | 5 +++-- app/core/users/utils_users.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/core/memberships/schemas_memberships.py b/app/core/memberships/schemas_memberships.py index 04611f6e76..f78901ed98 100644 --- a/app/core/memberships/schemas_memberships.py +++ b/app/core/memberships/schemas_memberships.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict from app.core.documents import schemas_documents +from app.core.documents.types_documenso import DocumentStatus from app.core.users import schemas_users @@ -53,7 +54,7 @@ class UserMembershipSimple(UserMembershipBase): id: UUID user_id: str document_id: UUID | None = None - document_status: schemas_documents.DocumentStatus | None = None + document_status: DocumentStatus | None = None valid: bool model_config = ConfigDict(from_attributes=True) @@ -75,7 +76,7 @@ class UserMembershipWithAssociation(UserMembershipComplete): class UserMembershipEdit(BaseModel): start_date: date | None = None end_date: date | None = None - document_status: schemas_documents.DocumentStatus | None = None + document_status: DocumentStatus | None = None class MembershipUserMappingEmail(BaseModel): diff --git a/app/core/users/utils_users.py b/app/core/users/utils_users.py index b135cde862..c29f03a5c8 100644 --- a/app/core/users/utils_users.py +++ b/app/core/users/utils_users.py @@ -1,3 +1,5 @@ +from app.core.groups import schemas_groups +from app.core.schools import schemas_schools from app.core.users import models_users, schemas_users @@ -33,10 +35,10 @@ def user_model_to_schema( phone=model.phone, created_on=model.created_on, groups=[ - schemas_users.CoreGroupSimple(id=group.id, name=group.name) + schemas_groups.CoreGroupSimple(id=group.id, name=group.name) for group in model.groups ], - school=schemas_users.CoreSchool( + school=schemas_schools.CoreSchool( id=model.school.id, name=model.school.name, email_regex=model.school.email_regex, From f3b19a289220f3e0acdef762806163d507c45f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Thu, 2 Jul 2026 16:09:55 +0200 Subject: [PATCH 03/13] feat: adapt tests --- app/modules/cdr/endpoints_cdr.py | 66 +++++++------- tests/modules/cdr/test_cdr.py | 87 +++++++++++++++++-- tests/modules/cdr/test_cdr_result.py | 123 +++++++++++++++++---------- 3 files changed, 192 insertions(+), 84 deletions(-) diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index e1674c7bca..e100fbecbc 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -1,7 +1,7 @@ import logging import re from collections.abc import Sequence -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta from io import BytesIO from uuid import UUID, uuid4 @@ -1814,44 +1814,40 @@ async def add_membership( ), None, ) + start_date = date(datetime.now(tz=UTC).date().year, 9, 1) + end_date = start_date + product_variant.related_membership_added_duration if existing_membership: - await cruds_memberships.update_user_membership( - db=db, - user_membership_id=existing_membership.id, - user_membership_edit=schemas_memberships.UserMembershipEdit( - end_date=existing_membership.end_date - + product_variant.related_membership_added_duration, - ), - ) - else: - user = await cruds_users.get_user_by_id(db=db, user_id=user_id) - if not user: - raise PurchaseUserNotFoundError(user_id) - association_membership = ( - await cruds_memberships.get_association_membership_by_id( - db=db, - membership_id=product_related_membership_id, - ) + start_date = max( + existing_membership.end_date + timedelta(days=1), + start_date, ) - if not association_membership: - raise ProductAssociationMembershipNotFoundError( - product_variant.product_id, - ) - await add_membership_to_user( - user=user_model_to_schema(user), - association_membership=association_membership, + user = await cruds_users.get_user_by_id(db=db, user_id=user_id) + if not user: + raise PurchaseUserNotFoundError(user_id) + association_membership = ( + await cruds_memberships.get_association_membership_by_id( db=db, - user_membership=schemas_memberships.UserMembershipSimple( - id=uuid4(), - user_id=user_id, - association_membership_id=product_related_membership_id, - start_date=date(datetime.now(tz=UTC).date().year, 9, 1), - end_date=date(datetime.now(tz=UTC).date().year, 9, 1) - + product_variant.related_membership_added_duration, - valid=True, - ), - settings=settings, + membership_id=product_related_membership_id, + ) + ) + if not association_membership: + raise ProductAssociationMembershipNotFoundError( + product_variant.product_id, ) + await add_membership_to_user( + user=user_model_to_schema(user), + association_membership=association_membership, + db=db, + user_membership=schemas_memberships.UserMembershipSimple( + id=uuid4(), + user_id=user_id, + association_membership_id=product_related_membership_id, + start_date=start_date, + end_date=end_date, + valid=True, + ), + settings=settings, + ) @module.router.patch( diff --git a/tests/modules/cdr/test_cdr.py b/tests/modules/cdr/test_cdr.py index a0b04b4a2f..3abdf43319 100644 --- a/tests/modules/cdr/test_cdr.py +++ b/tests/modules/cdr/test_cdr.py @@ -3,8 +3,10 @@ import pytest_asyncio from fastapi.testclient import TestClient +from pydantic import BaseModel from pytest_mock import MockerFixture +from app.core.documents import models_documents from app.core.groups import models_groups from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships @@ -74,6 +76,8 @@ payment: models_cdr.Payment +document_team: models_documents.DocumentTeam +template: models_documents.DocumentTemplate association_membership: models_memberships.CoreAssociationMembership user_membership: models_memberships.CoreAssociationUserMembership @@ -350,11 +354,34 @@ async def init_objects(): ) await add_object_to_db(payment) + global document_team, template + document_team = models_documents.DocumentTeam( + id=uuid.uuid4(), + team_id=1, + group_id=admin_group.id, + name="Team", + api_key="team", + ) + await add_object_to_db(document_team) + + template = models_documents.DocumentTemplate( + id=uuid.uuid4(), + documenso_id=1, + name="Template", + team_id=document_team.id, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + deleted=False, + document_directory_id="1", + ) + await add_object_to_db(template) + global association_membership association_membership = models_memberships.CoreAssociationMembership( id=uuid.uuid4(), name="AEECL", manager_group_id=admin_group.id, + template_id=template.id, ) await add_object_to_db(association_membership) @@ -526,6 +553,31 @@ def test_get_cdr_user(client: TestClient): assert str(user.id) == response.json()["id"] +def test_get_cdr_user_not_found(client: TestClient): + response = client.get( + f"/cdr/users/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 404 + + +def test_get_cdr_user_forbidden(client: TestClient): + response = client.get( + f"/cdr/users/{user_admin.id}", + headers={"Authorization": f"Bearer {token_user}"}, + ) + assert response.status_code == 403 + + +def test_get_cdr_user_admin(client: TestClient): + response = client.get( + f"/cdr/users/{user.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 200 + assert str(user.id) == response.json()["id"] + + def test_get_all_sellers_admin(client: TestClient): response = client.get( "/cdr/sellers/", @@ -2491,7 +2543,17 @@ def test_get_ticket_list(client: TestClient): assert str(user.id) in [user["id"] for user in response.json()] -async def test_validate_purchase(client: TestClient): +class MockedRecipientResponse(BaseModel): + token: str + + +class MockedTemplateUseResponse(BaseModel): + id: int + recipients: list[MockedRecipientResponse] + title: str + + +async def test_validate_purchase(client: TestClient, mocker: MockerFixture): product_membership = models_cdr.CdrProduct( id=uuid.uuid4(), seller_id=seller.id, @@ -2533,6 +2595,7 @@ async def test_validate_purchase(client: TestClient): unique=False, enabled=True, year=year, + related_membership_added_duration=timedelta(days=365), ) await add_object_to_db(variant_to_validate) variant_purchased = models_cdr.ProductVariant( @@ -2576,29 +2639,43 @@ async def test_validate_purchase(client: TestClient): ) await add_object_to_db(membership) + mocked_id = uuid.uuid4() + mocker.patch( + "app.core.documents.utils_documents.uuid.uuid4", + return_value=mocked_id, + ) + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", + return_value=MockedTemplateUseResponse( + id=100, + recipients=[MockedRecipientResponse(token="mocked_signing_token")], + title="Mocked Document Title", + ), + ) + response = client.patch( f"/cdr/users/{user.id}/purchases/{variant_to_validate.id}/validated/?validated=True", headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 204 + assert response.status_code == 204, response.text response = client.delete( f"/cdr/users/{user.id}/purchases/{variant_purchased.id}/", headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 403 + assert response.status_code == 403, response.text response = client.patch( f"/cdr/users/{user.id}/purchases/{variant_to_validate.id}/validated/?validated=False", headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 204 + assert response.status_code == 204, response.text response = client.delete( f"/cdr/users/{user.id}/purchases/{variant_purchased.id}/", headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 204 + assert response.status_code == 204, response.text def test_create_customdata_field(client: TestClient): diff --git a/tests/modules/cdr/test_cdr_result.py b/tests/modules/cdr/test_cdr_result.py index 48c0ff7596..44791b40c8 100644 --- a/tests/modules/cdr/test_cdr_result.py +++ b/tests/modules/cdr/test_cdr_result.py @@ -3,6 +3,7 @@ from io import BytesIO import pytest_asyncio +from fastapi.testclient import TestClient from app.core.groups import models_groups from app.core.users import models_users @@ -10,6 +11,7 @@ from app.modules.cdr.endpoints_cdr import CdrPermissions from app.modules.cdr.utils_cdr import construct_dataframe_from_users_purchases from tests.commons import ( + add_object_to_db, create_api_access_token, create_groups_with_permissions, create_user_with_groups, @@ -109,6 +111,7 @@ async def init_objects(): group_id=str(seller1_group.id), order=1, ) + await add_object_to_db(seller1) global seller2 seller2 = models_cdr.Seller( @@ -117,6 +120,7 @@ async def init_objects(): group_id=str(seller2_group.id), order=2, ) + await add_object_to_db(seller2) global product1 product1 = models_cdr.CdrProduct( @@ -130,6 +134,7 @@ async def init_objects(): year=datetime.now(UTC).year, needs_validation=True, ) + await add_object_to_db(product1) global product2 product2 = models_cdr.CdrProduct( @@ -143,6 +148,7 @@ async def init_objects(): year=datetime.now(UTC).year, needs_validation=True, ) + await add_object_to_db(product2) global product3 product3 = models_cdr.CdrProduct( @@ -156,6 +162,7 @@ async def init_objects(): year=datetime.now(UTC).year, needs_validation=True, ) + await add_object_to_db(product3) global customdata_field1 customdata_field1 = models_cdr.CustomDataField( @@ -164,6 +171,7 @@ async def init_objects(): name="Champ 1", can_user_answer=False, ) + await add_object_to_db(customdata_field1) global customdata_field2 customdata_field2 = models_cdr.CustomDataField( @@ -172,6 +180,7 @@ async def init_objects(): name="Champ 2", can_user_answer=False, ) + await add_object_to_db(customdata_field2) global product1_variant1 product1_variant1 = models_cdr.ProductVariant( @@ -184,6 +193,7 @@ async def init_objects(): enabled=True, year=datetime.now(UTC).year, ) + await add_object_to_db(product1_variant1) global product1_variant2 product1_variant2 = models_cdr.ProductVariant( @@ -196,6 +206,7 @@ async def init_objects(): enabled=True, year=datetime.now(UTC).year, ) + await add_object_to_db(product1_variant2) global product1_variant3 product1_variant3 = models_cdr.ProductVariant( @@ -208,6 +219,7 @@ async def init_objects(): enabled=True, year=datetime.now(UTC).year, ) + await add_object_to_db(product1_variant3) global product2_variant1 product2_variant1 = models_cdr.ProductVariant( @@ -220,6 +232,7 @@ async def init_objects(): enabled=True, year=datetime.now(UTC).year, ) + await add_object_to_db(product2_variant1) global product3_variant1 product3_variant1 = models_cdr.ProductVariant( @@ -232,6 +245,7 @@ async def init_objects(): enabled=True, year=datetime.now(UTC).year, ) + await add_object_to_db(product3_variant1) global purchase_user1_product1_variant1 purchase_user1_product1_variant1 = models_cdr.Purchase( @@ -241,6 +255,7 @@ async def init_objects(): validated=True, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user1_product1_variant1) global purchase_user1_product2_variant1 purchase_user1_product2_variant1 = models_cdr.Purchase( @@ -250,6 +265,7 @@ async def init_objects(): validated=False, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user1_product2_variant1) global purchase_user1_product3_variant1 purchase_user1_product3_variant1 = models_cdr.Purchase( @@ -259,6 +275,7 @@ async def init_objects(): validated=False, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user1_product3_variant1) global purchase_user2_product1_variant2 purchase_user2_product1_variant2 = models_cdr.Purchase( @@ -268,6 +285,7 @@ async def init_objects(): validated=False, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user2_product1_variant2) global purchase_user2_product2_variant1 purchase_user2_product2_variant1 = models_cdr.Purchase( @@ -277,6 +295,7 @@ async def init_objects(): validated=False, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user2_product2_variant1) global purchase_user3_product1_variant3 purchase_user3_product1_variant3 = models_cdr.Purchase( @@ -286,6 +305,7 @@ async def init_objects(): validated=True, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user3_product1_variant3) global purchase_user3_product3_variant1 purchase_user3_product3_variant1 = models_cdr.Purchase( @@ -295,6 +315,7 @@ async def init_objects(): validated=False, purchased_on=datetime.now(UTC), ) + await add_object_to_db(purchase_user3_product3_variant1) global customdata_user1 customdata_user1 = models_cdr.CustomData( @@ -302,6 +323,7 @@ async def init_objects(): field_id=customdata_field1.id, value="Value 1", ) + await add_object_to_db(customdata_user1) global customdata_user2 customdata_user2 = models_cdr.CustomData( @@ -309,6 +331,7 @@ async def init_objects(): field_id=customdata_field1.id, value="Value 2", ) + await add_object_to_db(customdata_user2) global customdata_user3 customdata_user3 = models_cdr.CustomData( @@ -316,48 +339,60 @@ async def init_objects(): field_id=customdata_field1.id, value="Value 3", ) - - -def test_construct_dataframe_from_users_purchases(): - users_purchases = { - cdr_user1.id: [ - purchase_user1_product1_variant1, - purchase_user1_product2_variant1, - ], - cdr_user2.id: [ - purchase_user2_product1_variant2, - purchase_user2_product2_variant1, - ], - cdr_user3.id: [ - purchase_user3_product1_variant3, - ], - } - users = [cdr_user1, cdr_user2, cdr_user3] - products = [product1, product2] - product_variants = [ - product1_variant1, - product1_variant2, - product1_variant3, - product2_variant1, - ] - customdata_fields = { - product1.id: [customdata_field1], - product2.id: [customdata_field2], - } - users_answers = { - cdr_user1.id: [customdata_user1], - cdr_user2.id: [customdata_user2], - cdr_user3.id: [customdata_user3], - } - - excel_io = BytesIO() - - construct_dataframe_from_users_purchases( - users=users, - products=products, - variants=product_variants, - users_purchases=users_purchases, - data_fields=customdata_fields, - users_answers=users_answers, - export_io=excel_io, + await add_object_to_db(customdata_user3) + + +# def test_construct_dataframe_from_users_purchases(): +# users_purchases = { +# cdr_user1.id: [ +# purchase_user1_product1_variant1, +# purchase_user1_product2_variant1, +# ], +# cdr_user2.id: [ +# purchase_user2_product1_variant2, +# purchase_user2_product2_variant1, +# ], +# cdr_user3.id: [ +# purchase_user3_product1_variant3, +# ], +# } +# users = [cdr_user1, cdr_user2, cdr_user3] +# products = [product1, product2] +# product_variants = [ +# product1_variant1, +# product1_variant2, +# product1_variant3, +# product2_variant1, +# ] +# customdata_fields = { +# product1.id: [customdata_field1], +# product2.id: [customdata_field2], +# } +# users_answers = { +# cdr_user1.id: [customdata_user1], +# cdr_user2.id: [customdata_user2], +# cdr_user3.id: [customdata_user3], +# } + +# excel_io = BytesIO() + + +# construct_dataframe_from_users_purchases( +# users=users, +# products=products, +# variants=product_variants, +# users_purchases=users_purchases, +# data_fields=customdata_fields, +# users_answers=users_answers, +# export_io=excel_io, +# ) +def test_get_seller_result(client: TestClient): + response = client.get( + f"/cdr/sellers/{seller1.id}/results/", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 200, response.text + assert ( + response.headers["content-type"] + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) From 683958970f4792e478c169ac160a662a2b94d1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Thu, 2 Jul 2026 16:11:56 +0200 Subject: [PATCH 04/13] fix: linting --- tests/modules/cdr/test_cdr_result.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/modules/cdr/test_cdr_result.py b/tests/modules/cdr/test_cdr_result.py index 44791b40c8..7a101c6303 100644 --- a/tests/modules/cdr/test_cdr_result.py +++ b/tests/modules/cdr/test_cdr_result.py @@ -1,6 +1,5 @@ import uuid from datetime import UTC, datetime -from io import BytesIO import pytest_asyncio from fastapi.testclient import TestClient @@ -9,7 +8,6 @@ from app.core.users import models_users from app.modules.cdr import models_cdr from app.modules.cdr.endpoints_cdr import CdrPermissions -from app.modules.cdr.utils_cdr import construct_dataframe_from_users_purchases from tests.commons import ( add_object_to_db, create_api_access_token, From bc8833c1c26b967acfca4ca14543cc5593c2e93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 3 Jul 2026 09:59:00 +0200 Subject: [PATCH 05/13] feat: better tests --- app/core/memberships/cruds_memberships.py | 32 +- app/core/memberships/endpoints_memberships.py | 6 +- app/core/memberships/utils_memberships.py | 9 +- ..._document.py => 61-membership_document.py} | 0 tests/core/test_memberships.py | 286 ++++++++++++++++-- 5 files changed, 287 insertions(+), 46 deletions(-) rename migrations/versions/{60-membership_document.py => 61-membership_document.py} (100%) diff --git a/app/core/memberships/cruds_memberships.py b/app/core/memberships/cruds_memberships.py index 13e1737220..96c0d39517 100644 --- a/app/core/memberships/cruds_memberships.py +++ b/app/core/memberships/cruds_memberships.py @@ -65,13 +65,14 @@ async def create_association_membership( db: AsyncSession, membership: schemas_memberships.MembershipSimple, ): - membership_db = models_memberships.CoreAssociationMembership( - id=membership.id, - name=membership.name, - manager_group_id=membership.manager_group_id, - template_id=membership.template_id, + db.add( + models_memberships.CoreAssociationMembership( + id=membership.id, + name=membership.name, + manager_group_id=membership.manager_group_id, + template_id=membership.template_id, + ), ) - db.add(membership_db) async def delete_association_membership( @@ -282,16 +283,17 @@ async def create_user_membership( db: AsyncSession, user_membership: schemas_memberships.UserMembershipSimple, ): - membership_db = models_memberships.CoreAssociationUserMembership( - id=user_membership.id, - user_id=user_membership.user_id, - association_membership_id=user_membership.association_membership_id, - start_date=user_membership.start_date, - end_date=user_membership.end_date, - document_id=user_membership.document_id, - document_status=user_membership.document_status, + db.add( + models_memberships.CoreAssociationUserMembership( + id=user_membership.id, + user_id=user_membership.user_id, + association_membership_id=user_membership.association_membership_id, + start_date=user_membership.start_date, + end_date=user_membership.end_date, + document_id=user_membership.document_id, + document_status=user_membership.document_status, + ), ) - db.add(membership_db) async def delete_user_membership( diff --git a/app/core/memberships/endpoints_memberships.py b/app/core/memberships/endpoints_memberships.py index e9ab8ad73c..2ccdf1ba9f 100644 --- a/app/core/memberships/endpoints_memberships.py +++ b/app/core/memberships/endpoints_memberships.py @@ -371,10 +371,14 @@ async def read_user_memberships( user_id, manager_restriction=[group.id for group in user.groups], ) - return await cruds_memberships.get_user_memberships_by_user_id( + memberships = await cruds_memberships.get_user_memberships_by_user_id( db, user_id, ) + hyperion_error_logger.error( + f"User {user.id} accessed memberships for user {user_id}: {memberships}", + ) + return memberships @router.get( diff --git a/app/core/memberships/utils_memberships.py b/app/core/memberships/utils_memberships.py index 3d41e8b42d..0b4876afaf 100644 --- a/app/core/memberships/utils_memberships.py +++ b/app/core/memberships/utils_memberships.py @@ -1,3 +1,4 @@ +import logging from datetime import UTC, datetime from uuid import UUID @@ -27,6 +28,8 @@ MODULE_ROOT = "memberships" +hyperion_error_logger = logging.getLogger("hyperion.error") + def membership_model_to_schema( model: models_memberships.CoreAssociationMembership, @@ -194,7 +197,7 @@ async def add_membership_to_user( user_membership: schemas_memberships.UserMembershipSimple, db: AsyncSession, settings: Settings, -): +) -> schemas_memberships.UserMembershipComplete: """ Add a membership to a user. :param user: The user to add the membership to. @@ -224,12 +227,16 @@ async def add_membership_to_user( db=db, documenso=documenso, ) + await db.flush() user_membership.document_id = document.id user_membership.document_status = document.status user_membership.valid = ( user_membership.document_status == DocumentStatus.COMPLETED ) + hyperion_error_logger.debug( + f"Adding membership {association_membership.id} to user {user.id} with membership data: {user_membership}", + ) await cruds_memberships.create_user_membership( db=db, user_membership=user_membership, diff --git a/migrations/versions/60-membership_document.py b/migrations/versions/61-membership_document.py similarity index 100% rename from migrations/versions/60-membership_document.py rename to migrations/versions/61-membership_document.py diff --git a/tests/core/test_memberships.py b/tests/core/test_memberships.py index 2b3459bf7c..6f3e3ccc8a 100644 --- a/tests/core/test_memberships.py +++ b/tests/core/test_memberships.py @@ -1,12 +1,18 @@ import uuid from datetime import UTC, date, datetime, timedelta +from uuid import uuid4 import pytest_asyncio from fastapi.testclient import TestClient +from pydantic import BaseModel +from pytest_mock import MockerFixture +from app.core.documents import models_documents +from app.core.documents.types_documenso import DocumentStatus from app.core.groups import models_groups from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships +from app.core.memberships.utils_memberships import MODULE_ROOT from app.core.users import models_users from tests.commons import ( add_object_to_db, @@ -27,9 +33,14 @@ token_user: str token_admin: str token_bde: str + +team: models_documents.DocumentTeam +template: models_documents.DocumentTemplate +document: models_documents.DocumentDocument + aeecl_association_membership: models_memberships.CoreAssociationMembership useecl_association_membership: models_memberships.CoreAssociationMembership -user_membership: models_memberships.CoreAssociationUserMembership +aeecl_user_membership: models_memberships.CoreAssociationUserMembership useecl_user_membership: models_memberships.CoreAssociationUserMembership @@ -67,6 +78,42 @@ async def init_objects(): token_admin = create_api_access_token(admin_user) token_bde = create_api_access_token(bde_user) + global team, template, document + team = models_documents.DocumentTeam( + id=uuid.uuid4(), + team_id=1, + group_id=bds_group.id, + name="Team", + api_key="team", + ) + await add_object_to_db(team) + + template = models_documents.DocumentTemplate( + id=uuid.uuid4(), + documenso_id=1, + name="Template", + team_id=team.id, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + deleted=False, + document_directory_id="1", + ) + await add_object_to_db(template) + + document = models_documents.DocumentDocument( + id=uuid.uuid4(), + documenso_id=1, + name="Document", + template_id=template.id, + module=MODULE_ROOT, + user_id=user.id, + status=DocumentStatus.COMPLETED, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + signing_token="token", + ) + await add_object_to_db(document) + global aeecl_association_membership, useecl_association_membership aeecl_association_membership = models_memberships.CoreAssociationMembership( id=uuid.uuid4(), @@ -78,18 +125,19 @@ async def init_objects(): id=uuid.uuid4(), name="USEECL", manager_group_id=bds_group.id, + template_id=template.id, ) await add_object_to_db(useecl_association_membership) - global user_membership, useecl_user_membership - user_membership = models_memberships.CoreAssociationUserMembership( + global aeecl_user_membership, useecl_user_membership + aeecl_user_membership = models_memberships.CoreAssociationUserMembership( id=uuid.uuid4(), user_id=user.id, association_membership_id=aeecl_association_membership.id, start_date=datetime.now(tz=UTC).date() - timedelta(days=365), end_date=datetime.now(tz=UTC).date() + timedelta(days=365), ) - await add_object_to_db(user_membership) + await add_object_to_db(aeecl_user_membership) useecl_user_membership = models_memberships.CoreAssociationUserMembership( id=uuid.uuid4(), @@ -97,6 +145,8 @@ async def init_objects(): association_membership_id=useecl_association_membership.id, start_date=datetime.now(tz=UTC).date() - timedelta(days=100), end_date=datetime.now(tz=UTC).date(), + document_id=document.id, + document_status=DocumentStatus.COMPLETED, ) await add_object_to_db(useecl_user_membership) @@ -111,6 +161,40 @@ def test_get_association_memberships(client: TestClient): assert str(useecl_association_membership.id) in [x["id"] for x in response.json()] +def test_get_association_membership_unknown(client: TestClient): + response = client.get( + f"/memberships/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {token_user}"}, + ) + assert response.status_code == 404 + + +def test_get_association_membership_lambda(client: TestClient): + response = client.get( + f"/memberships/{useecl_association_membership.id}", + headers={"Authorization": f"Bearer {token_user}"}, + ) + assert response.status_code == 403 + + +def test_get_association_membership_admin(client: TestClient): + response = client.get( + f"/memberships/{useecl_association_membership.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 200 + assert response.json()["id"] == str(useecl_association_membership.id) + assert response.json()["name"] == useecl_association_membership.name + assert ( + response.json()["manager_group_id"] + == useecl_association_membership.manager_group_id + ) + assert response.json()["template_id"] == str( + useecl_association_membership.template_id, + ) + assert response.json()["template"]["id"] == str(template.id) + + def test_create_association_membership_user(client: TestClient): response = client.post( "/memberships", @@ -123,6 +207,30 @@ def test_create_association_membership_user(client: TestClient): assert response.status_code == 403 +def test_create_association_membership_duplicate_name(client: TestClient): + response = client.post( + "/memberships", + json={ + "name": aeecl_association_membership.name, + "manager_group_id": dummy_group_1.id, + }, + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 400 + + +def test_create_association_membership_unknown_manager_group(client: TestClient): + response = client.post( + "/memberships", + json={ + "name": "Random Association", + "manager_group_id": str(uuid.uuid4()), + }, + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 404 + + def test_create_association_membership_admin(client: TestClient): response = client.post( "/memberships", @@ -207,6 +315,18 @@ async def test_delete_association_membership_admin(client: TestClient): assert str(new_membership.id) not in [x["id"] for x in response.json()] +def test_patch_association_membership_unknown(client: TestClient): + response = client.patch( + f"/memberships/{uuid.uuid4()}", + json={ + "name": "Random Association", + "manager_group_id": dummy_group_2.id, + }, + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 404 + + def test_patch_association_membership_user(client: TestClient): response = client.patch( f"/memberships/{aeecl_association_membership.id}", @@ -258,13 +378,94 @@ async def test_patch_association_membership_admin(client: TestClient): assert new_group_id in [x["manager_group_id"] for x in response.json()] +async def test_document_renewal_unknown_membership(client: TestClient): + response = client.post( + f"/memberships/{uuid.uuid4()}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + json={"active_date": datetime.now(tz=UTC).date().isoformat()}, + ) + assert response.status_code == 404 + + +async def test_document_renewal_user(client: TestClient): + response = client.post( + f"/memberships/{useecl_association_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_user}"}, + json={"active_date": datetime.now(tz=UTC).date().isoformat()}, + ) + assert response.status_code == 403 + + +async def test_document_renewal_no_template_id(client: TestClient): + response = client.post( + f"/memberships/{aeecl_association_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + json={"active_date": datetime.now(tz=UTC).date().isoformat()}, + ) + assert response.status_code == 400 + + +class MockedRecipientResponse(BaseModel): + token: str + + +class MockedTemplateUseResponse(BaseModel): + id: int + recipients: list[MockedRecipientResponse] + title: str + + +async def test_document_renewal_admin(client: TestClient, mocker: MockerFixture): + + mocked_id = uuid4() + mocker.patch( + "app.core.documents.utils_documents.uuid.uuid4", + return_value=mocked_id, + ) + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", + return_value=MockedTemplateUseResponse( + id=100, + recipients=[MockedRecipientResponse(token="mocked_signing_token")], + title="Mocked Document Title", + ), + ) + targeted_membership = models_memberships.CoreAssociationUserMembership( + id=uuid.uuid4(), + user_id=admin_user.id, + association_membership_id=useecl_association_membership.id, + start_date=datetime.now(tz=UTC).date() - timedelta(days=1000), + end_date=datetime.now(tz=UTC).date() + timedelta(days=750), + ) + await add_object_to_db(targeted_membership) + + response = client.post( + f"/memberships/{useecl_association_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + json={ + "active_date": (datetime.now(tz=UTC) + timedelta(days=2)) + .date() + .isoformat(), + }, + ) + assert response.status_code == 201 + + membership_response = client.get( + f"/memberships/users/{admin_user.id}/{useecl_association_membership.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert membership_response.status_code == 200 + membership_data = membership_response.json()[0] + assert membership_data["document_id"] == str(mocked_id) + + def test_get_memberships_by_user_id_user(client: TestClient): response = client.get( f"/memberships/users/{user.id}", headers={"Authorization": f"Bearer {token_user}"}, ) assert response.status_code == 200 - assert str(user_membership.id) in [x["id"] for x in response.json()] + assert str(aeecl_user_membership.id) in [x["id"] for x in response.json()] def test_get_memberships_by_user_id_admin(client: TestClient): @@ -273,7 +474,7 @@ def test_get_memberships_by_user_id_admin(client: TestClient): headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 200 - assert str(user_membership.id) in [x["id"] for x in response.json()] + assert str(aeecl_user_membership.id) in [x["id"] for x in response.json()] def test_get_memberships_by_user_id_manager(client: TestClient): @@ -282,7 +483,7 @@ def test_get_memberships_by_user_id_manager(client: TestClient): headers={"Authorization": f"Bearer {token_bde}"}, ) assert response.status_code == 200 - assert str(user_membership.id) in [x["id"] for x in response.json()] + assert str(aeecl_user_membership.id) in [x["id"] for x in response.json()] assert str(useecl_user_membership.id) not in [x["id"] for x in response.json()] @@ -292,10 +493,26 @@ def test_get_association_membership_by_user_id_manager(client: TestClient): headers={"Authorization": f"Bearer {token_bde}"}, ) assert response.status_code == 200 - assert str(user_membership.id) in [x["id"] for x in response.json()] + assert str(aeecl_user_membership.id) in [x["id"] for x in response.json()] + + +async def test_get_membership_members_unknown(client: TestClient): + response = client.get( + f"/memberships/{uuid.uuid4()}/members", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 404 + + +async def test_get_membership_members_user(client: TestClient): + response = client.get( + f"/memberships/{useecl_association_membership.id}/members", + headers={"Authorization": f"Bearer {token_user}"}, + ) + assert response.status_code == 403 -async def test_get_membership_with_date_filter(client: TestClient): +async def test_get_membership_members_with_date_filter(client: TestClient): today = datetime.now(tz=UTC).date() new_membership1 = models_memberships.CoreAssociationUserMembership( id=uuid.uuid4(), @@ -417,10 +634,19 @@ def test_create_user_membership_with_overlapping_dates(client: TestClient): assert response.status_code == 400 -def test_create_user_membership_admin(client: TestClient): +def test_create_user_membership_admin(client: TestClient, mocker: MockerFixture): + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", + return_value=MockedTemplateUseResponse( + id=101, + recipients=[MockedRecipientResponse(token="mocked_signing_token")], + title="Mocked Document Title", + ), + ) + today = datetime.now(tz=UTC).date() response = client.post( - f"/memberships/users/{user.id}", + f"/memberships/users/{bde_user.id}", json={ "association_membership_id": str(useecl_association_membership.id), "start_date": str(today - timedelta(days=1000)), @@ -429,14 +655,14 @@ def test_create_user_membership_admin(client: TestClient): headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 201 - membership_id = uuid.UUID(response.json()["id"]) + membership_id = response.json()["id"] response = client.get( - f"/memberships/users/{user.id}", + f"/memberships/users/{bde_user.id}", headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 200 - assert str(membership_id) in [x["id"] for x in response.json()] + assert membership_id in [x["id"] for x in response.json()] def test_create_user_membership_manager(client: TestClient): @@ -453,17 +679,19 @@ def test_create_user_membership_manager(client: TestClient): assert response.status_code == 201 membership_id = uuid.UUID(response.json()["id"]) - response = client.get( + memberships_response = client.get( f"/memberships/users/{bde_user.id}", headers={"Authorization": f"Bearer {token_admin}"}, ) - assert response.status_code == 200 - assert str(membership_id) in [x["id"] for x in response.json()] + assert memberships_response.status_code == 200 + assert str(membership_id) in [x["id"] for x in memberships_response.json()], ( + response.json() + ) def test_delete_user_membership_user(client: TestClient): response = client.delete( - f"/memberships/users/{user_membership.id}", + f"/memberships/users/{aeecl_user_membership.id}", headers={"Authorization": f"Bearer {token_user}"}, ) assert response.status_code == 403 @@ -473,7 +701,7 @@ def test_delete_user_membership_user(client: TestClient): headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 200 - assert str(user_membership.id) in [x["id"] for x in response.json()] + assert str(aeecl_user_membership.id) in [x["id"] for x in response.json()] def test_delete_user_membership_wrong_id(client: TestClient): @@ -534,7 +762,7 @@ async def test_delete_user_membership_manager(client: TestClient): def test_patch_user_membership_user(client: TestClient): response = client.patch( - f"/memberships/users/{user_membership.id}", + f"/memberships/users/{aeecl_user_membership.id}", json={ "association_membership_id": str(useecl_association_membership.id), "start_date": str(date(2024, 6, 1)), @@ -559,7 +787,7 @@ def test_patch_user_membership_wrong_id(client: TestClient): def test_patch_user_membership_with_wrong_dates(client: TestClient): response = client.patch( - f"/memberships/users/{user_membership.id}", + f"/memberships/users/{aeecl_user_membership.id}", json={ "start_date": str(date(2028, 6, 1)), "end_date": str(date(2024, 6, 1)), @@ -598,13 +826,13 @@ async def test_patch_user_membership_admin(client: TestClient): id=uuid.uuid4(), user_id=user.id, association_membership_id=aeecl_association_membership.id, - start_date=user_membership.end_date + timedelta(days=90), - end_date=user_membership.end_date + timedelta(days=500), + start_date=aeecl_user_membership.end_date + timedelta(days=90), + end_date=aeecl_user_membership.end_date + timedelta(days=500), ) await add_object_to_db(new_membership) - new_start_date = str(user_membership.end_date + timedelta(days=100)) - new_end_date = str(user_membership.end_date + timedelta(days=1000)) + new_start_date = str(aeecl_user_membership.end_date + timedelta(days=100)) + new_end_date = str(aeecl_user_membership.end_date + timedelta(days=1000)) response = client.patch( f"/memberships/users/{new_membership.id}", json={ @@ -632,13 +860,13 @@ async def test_patch_user_membership_manager(client: TestClient): id=uuid.uuid4(), user_id=bde_user.id, association_membership_id=aeecl_association_membership.id, - start_date=user_membership.end_date + timedelta(days=90), - end_date=user_membership.end_date + timedelta(days=500), + start_date=aeecl_user_membership.end_date + timedelta(days=90), + end_date=aeecl_user_membership.end_date + timedelta(days=500), ) await add_object_to_db(new_membership) - new_start_date = str(user_membership.end_date + timedelta(days=100)) - new_end_date = str(user_membership.end_date + timedelta(days=1000)) + new_start_date = str(aeecl_user_membership.end_date + timedelta(days=100)) + new_end_date = str(aeecl_user_membership.end_date + timedelta(days=1000)) response = client.patch( f"/memberships/users/{new_membership.id}", json={ From e48dfad56f11910ff5582f63c37d0ac0e26e7885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 3 Jul 2026 12:40:28 +0200 Subject: [PATCH 06/13] feat: better tests and MyPayment modification --- app/core/memberships/endpoints_memberships.py | 13 ++- app/core/mypayment/endpoints_mypayment.py | 4 +- tests/core/test_memberships.py | 109 +++++++++++++++--- 3 files changed, 104 insertions(+), 22 deletions(-) diff --git a/app/core/memberships/endpoints_memberships.py b/app/core/memberships/endpoints_memberships.py index 2ccdf1ba9f..9a405abf14 100644 --- a/app/core/memberships/endpoints_memberships.py +++ b/app/core/memberships/endpoints_memberships.py @@ -21,6 +21,7 @@ from app.core.memberships.utils_memberships import ( MODULE_ROOT, add_membership_to_user, + remove_membership_from_user, renew_membership_documents, validate_user_new_membership, ) @@ -551,7 +552,6 @@ async def add_batch_membership( ) except HTTPException: pass - await db.flush() return unknown_users @@ -610,6 +610,7 @@ async def delete_user_membership( membership_id: uuid.UUID, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user()), + settings=Depends(get_settings), ): """ Delete a user membership. @@ -636,9 +637,10 @@ async def delete_user_membership( ): raise HTTPException(status_code=403, detail="Unauthorized") - await cruds_memberships.delete_user_membership( + await remove_membership_from_user( + user_membership=db_user_membership, + settings=settings, db=db, - user_membership_id=membership_id, ) @@ -679,13 +681,14 @@ async def synchronize_membership_with_group( minimal_end_date=datetime.now(UTC).date(), ) ) + unique_membership_user_ids = {member.user_id for member in membership_members} await cruds_groups.delete_membership_by_group_id( group_id=group_id, db=db, ) - for member in membership_members: + for user_id in unique_membership_user_ids: membership = models_groups.CoreMembership( - user_id=member.user_id, + user_id=user_id, group_id=group_id, description=None, ) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 1c484afbc7..290aeff775 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -2278,7 +2278,7 @@ async def validate_can_scan_qrcode( association_membership_id=store.structure.association_membership_id, db=db, ) - if result is None: + if result is None or not result.valid: return standard_responses.Result(success=False) return standard_responses.Result(success=True) @@ -2465,7 +2465,7 @@ async def store_scan_qrcode( association_membership_id=store.structure.association_membership_id, db=db, ) - if current_membership is None: + if current_membership is None or not current_membership.valid: raise HTTPException( status_code=400, detail="User is not a member of the association", diff --git a/tests/core/test_memberships.py b/tests/core/test_memberships.py index 6f3e3ccc8a..e07797a6a6 100644 --- a/tests/core/test_memberships.py +++ b/tests/core/test_memberships.py @@ -11,14 +11,18 @@ from app.core.documents.types_documenso import DocumentStatus from app.core.groups import models_groups from app.core.groups.groups_type import GroupType -from app.core.memberships import models_memberships -from app.core.memberships.utils_memberships import MODULE_ROOT +from app.core.memberships import cruds_memberships, models_memberships +from app.core.memberships.utils_memberships import ( + MODULE_ROOT, + membership_document_callback, +) from app.core.users import models_users from tests.commons import ( add_object_to_db, create_api_access_token, create_groups_with_permissions, create_user_with_groups, + get_TestingSessionLocal, ) bde_group: models_groups.CoreGroup @@ -146,7 +150,7 @@ async def init_objects(): start_date=datetime.now(tz=UTC).date() - timedelta(days=100), end_date=datetime.now(tz=UTC).date(), document_id=document.id, - document_status=DocumentStatus.COMPLETED, + document_status=DocumentStatus.PENDING, ) await add_object_to_db(useecl_user_membership) @@ -422,7 +426,7 @@ async def test_document_renewal_admin(client: TestClient, mocker: MockerFixture) "app.core.documents.utils_documents.uuid.uuid4", return_value=mocked_id, ) - mocker.patch( + mock_use = mocker.patch( "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", return_value=MockedTemplateUseResponse( id=100, @@ -449,6 +453,7 @@ async def test_document_renewal_admin(client: TestClient, mocker: MockerFixture) }, ) assert response.status_code == 201 + assert mock_use.called membership_response = client.get( f"/memberships/users/{admin_user.id}/{useecl_association_membership.id}", @@ -635,7 +640,7 @@ def test_create_user_membership_with_overlapping_dates(client: TestClient): def test_create_user_membership_admin(client: TestClient, mocker: MockerFixture): - mocker.patch( + mock_use = mocker.patch( "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", return_value=MockedTemplateUseResponse( id=101, @@ -655,6 +660,7 @@ def test_create_user_membership_admin(client: TestClient, mocker: MockerFixture) headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 201 + assert mock_use.called membership_id = response.json()["id"] response = client.get( @@ -712,13 +718,34 @@ def test_delete_user_membership_wrong_id(client: TestClient): assert response.status_code == 404 -async def test_delete_user_membership_admin(client: TestClient): +async def test_delete_user_membership_admin(client: TestClient, mocker: MockerFixture): + mock_delete = mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.delete_document", + return_value=None, + ) + + new_document = models_documents.DocumentDocument( + id=uuid.uuid4(), + documenso_id=2, + name="Document", + template_id=template.id, + module=MODULE_ROOT, + user_id=user.id, + status=DocumentStatus.COMPLETED, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + signing_token="token", + ) + await add_object_to_db(new_document) + new_membership = models_memberships.CoreAssociationUserMembership( id=uuid.uuid4(), user_id=user.id, association_membership_id=useecl_association_membership.id, start_date=datetime.now(tz=UTC).date() - timedelta(days=365), end_date=datetime.now(tz=UTC).date() + timedelta(days=365), + document_id=new_document.id, + document_status=DocumentStatus.COMPLETED, ) await add_object_to_db(new_membership) @@ -727,6 +754,7 @@ async def test_delete_user_membership_admin(client: TestClient): headers={"Authorization": f"Bearer {token_admin}"}, ) assert response.status_code == 204 + assert mock_delete.called response = client.get( f"/memberships/users/{user.id}", @@ -736,7 +764,15 @@ async def test_delete_user_membership_admin(client: TestClient): assert str(new_membership.id) not in [x["id"] for x in response.json()] -async def test_delete_user_membership_manager(client: TestClient): +async def test_delete_user_membership_manager( + client: TestClient, + mocker: MockerFixture, +): + mock_delete = mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.delete_document", + return_value=None, + ) + new_membership = models_memberships.CoreAssociationUserMembership( id=uuid.uuid4(), user_id=bde_user.id, @@ -751,6 +787,7 @@ async def test_delete_user_membership_manager(client: TestClient): headers={"Authorization": f"Bearer {token_bde}"}, ) assert response.status_code == 204 + assert not mock_delete.called response = client.get( f"/memberships/users/{user.id}", @@ -948,14 +985,16 @@ async def test_post_batch_user_memberships_admin(client: TestClient): for x in response.json() if x["association_membership_id"] == str(aeecl_association_membership.id) ] - seen = False - for membership in aeecl_memberships: - if membership["start_date"] == str(today - timedelta(days=1000)) and membership[ - "end_date" - ] == str(today + timedelta(days=365)): - assert not seen - seen = True - assert seen + membership = next( + ( + x + for x in aeecl_memberships + if x["start_date"] == str(today - timedelta(days=1000)) + and x["end_date"] == str(today + timedelta(days=365)) + ), + None, + ) + assert membership is not None membership = next( ( x @@ -966,3 +1005,43 @@ async def test_post_batch_user_memberships_admin(client: TestClient): None, ) assert membership is not None + + +async def test_synchronize(client: TestClient): + group_membership = models_groups.CoreMembership( + user_id=bde_user.id, + group_id=dummy_group_1.id, + description=None, + ) + await add_object_to_db(group_membership) + + response = client.post( + f"/memberships/{aeecl_association_membership.id}/group/{dummy_group_1.id}/synchronize", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 201 + + response = client.get( + f"/groups/{dummy_group_1.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 200 + members = response.json()["members"] + assert any(member["id"] == str(user.id) for member in members) + assert not any(member["id"] == str(bde_user.id) for member in members) + + +async def test_document_callback(client: TestClient, mocker: MockerFixture): + async with get_TestingSessionLocal()() as db: + await membership_document_callback( + db=db, + document_id=document.id, + document_status=DocumentStatus.COMPLETED, + ) + + membership = await cruds_memberships.get_user_membership_by_id( + user_membership_id=useecl_user_membership.id, + db=db, + ) + assert membership is not None + assert membership.document_status == DocumentStatus.COMPLETED From 0b39e77ad813b2f1c19a954a114d863dcf83097f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Jul 2026 11:53:00 +0200 Subject: [PATCH 07/13] feat: allows single membership document renewal --- app/core/memberships/endpoints_memberships.py | 69 +++++++++++++++++++ tests/core/test_memberships.py | 67 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/app/core/memberships/endpoints_memberships.py b/app/core/memberships/endpoints_memberships.py index 9a405abf14..ce075648c4 100644 --- a/app/core/memberships/endpoints_memberships.py +++ b/app/core/memberships/endpoints_memberships.py @@ -602,6 +602,75 @@ async def update_user_membership( ) +@router.post( + "/memberships/users/{membership_id}/renew-documents", + status_code=201, + response_model=schemas_memberships.MembershipRenewalErrors, +) +async def renew_user_membership_document( + membership_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), + settings=Depends(get_settings), +): + """ + Renew the documents of a user membership. + + **This endpoint is only usable by administrators and membership managers** + """ + db_user_membership = await cruds_memberships.get_user_membership_by_id( + db=db, + user_membership_id=membership_id, + ) + if db_user_membership is None: + raise HTTPException(status_code=404, detail="User membership not found") + db_association_membership = ( + await cruds_memberships.get_association_membership_by_id( + db, + db_user_membership.association_membership_id, + ) + ) + if db_association_membership is None: + raise ValueError + if not is_user_member_of_any_group( + user, + [GroupType.admin, db_association_membership.manager_group_id], + ): + raise HTTPException(status_code=403, detail="Unauthorized") + + if db_association_membership.template is None: + raise HTTPException( + status_code=400, + detail="Association Membership has no template associated", + ) + team = await cruds_documents.get_team_by_id( + db=db, + team_id=db_association_membership.template.team_id, + ) + if team is None: + raise ElementTeamNotFoundError( + team_id=db_association_membership.template.team_id, + ) + + try: + await renew_membership_documents( + association_membership=db_association_membership, + team=team, + user_membership=db_user_membership, + db=db, + settings=settings, + ) + return schemas_memberships.MembershipRenewalErrors(errors={}) + except Exception as e: + if isinstance(e, DocumentCreationError): + return schemas_memberships.MembershipRenewalErrors( + errors={e.user_email: e.message}, + ) + return schemas_memberships.MembershipRenewalErrors( + errors={db_user_membership.user_id: str(e)}, + ) + + @router.delete( "/memberships/users/{membership_id}", status_code=204, diff --git a/tests/core/test_memberships.py b/tests/core/test_memberships.py index e07797a6a6..f49938a7e1 100644 --- a/tests/core/test_memberships.py +++ b/tests/core/test_memberships.py @@ -1007,6 +1007,73 @@ async def test_post_batch_user_memberships_admin(client: TestClient): assert membership is not None +async def test_user_document_renewal_unknown_membership(client: TestClient): + response = client.post( + f"/memberships/users/{uuid.uuid4()}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 404 + + +async def test_user_document_renewal_user(client: TestClient): + response = client.post( + f"/memberships/users/{useecl_user_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_user}"}, + ) + assert response.status_code == 403 + + +async def test_user_document_renewal_no_template_id(client: TestClient): + response = client.post( + f"/memberships/users/{aeecl_user_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 400 + + +async def test_user_document_renewal_admin(client: TestClient, mocker: MockerFixture): + + mocked_id = uuid4() + mocker.patch( + "app.core.documents.utils_documents.uuid.uuid4", + return_value=mocked_id, + ) + mock_use = mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.use_template", + return_value=MockedTemplateUseResponse( + id=1100, + recipients=[MockedRecipientResponse(token="mocked_signing_token")], + title="Mocked Document Title", + ), + ) + + targeted_membership = models_memberships.CoreAssociationUserMembership( + id=uuid.uuid4(), + user_id=bde_user.id, + association_membership_id=useecl_association_membership.id, + start_date=datetime.now(tz=UTC).date() - timedelta(days=1000), + end_date=datetime.now(tz=UTC).date() + timedelta(days=750), + ) + await add_object_to_db(targeted_membership) + + response = client.post( + f"/memberships/users/{targeted_membership.id}/renew-documents", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 201 + assert mock_use.called + + membership_response = client.get( + f"/memberships/users/{bde_user.id}/{useecl_association_membership.id}", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert membership_response.status_code == 200 + assert any( + membership["document_id"] == str(mocked_id) + for membership in membership_response.json() + ) + + async def test_synchronize(client: TestClient): group_membership = models_groups.CoreMembership( user_id=bde_user.id, From 0386a3f6397adda09b4df6ad08423373c1dfd864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Wed, 8 Jul 2026 10:34:32 +0200 Subject: [PATCH 08/13] feat: support duplicate --- app/core/documents/endpoints_documents.py | 16 +++++++++++++--- app/core/documents/schemas_documents.py | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/core/documents/endpoints_documents.py b/app/core/documents/endpoints_documents.py index 8f35087705..b47bc7b449 100644 --- a/app/core/documents/endpoints_documents.py +++ b/app/core/documents/endpoints_documents.py @@ -330,7 +330,7 @@ async def update_template( ) async def use_template( template_id: uuid.UUID, - recipient_list: schemas_documents.TemplateUse, + parameters: schemas_documents.TemplateUse, db: AsyncSession = Depends(get_db), user: schemas_users.CoreUser = Depends(is_user()), settings: Settings = Depends(get_settings), @@ -373,12 +373,22 @@ async def use_template( errors: dict[str, str] = {} users = await cruds_users.get_users_by_emails( db=db, - emails=recipient_list.recipients, + emails=list(set(parameters.recipients)), ) found_emails = [user.email for user in users] - for email in recipient_list.recipients: + for email in parameters.recipients: if email not in found_emails: errors[email] = "User not found" + if not parameters.allow_duplicate: + existing_documents = await cruds_documents.get_documents_by_template_id( + db=db, + template_id=template_id, + ) + existing_user_ids = {doc.user_id for doc in existing_documents} + existing_users = {user for user in users if user.id in existing_user_ids} + users = {user for user in users if user.id not in existing_user_ids} + for each_user in existing_users: + errors[each_user.email] = "Document already exists for this user" # Retrieve the target user to fill in the recipient fields documents = await asyncio.gather( diff --git a/app/core/documents/schemas_documents.py b/app/core/documents/schemas_documents.py index 333a0e1ede..899f80830c 100644 --- a/app/core/documents/schemas_documents.py +++ b/app/core/documents/schemas_documents.py @@ -60,6 +60,7 @@ class TemplateDocumensoUpdate(BaseModel): class TemplateUse(BaseModel): recipients: list[str] + allow_duplicate: bool | None = False class TemplateUseResponse(BaseModel): From c3ded41da843fc1929131486cc50aa25b455b3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 10 Jul 2026 11:09:51 +0200 Subject: [PATCH 09/13] fix: tests --- app/core/documents/endpoints_documents.py | 8 +++++--- app/core/users/schemas_users.py | 3 +++ tests/core/test_documents.py | 21 ++++++++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/core/documents/endpoints_documents.py b/app/core/documents/endpoints_documents.py index b47bc7b449..802372684c 100644 --- a/app/core/documents/endpoints_documents.py +++ b/app/core/documents/endpoints_documents.py @@ -371,9 +371,11 @@ async def use_template( ) errors: dict[str, str] = {} - users = await cruds_users.get_users_by_emails( - db=db, - emails=list(set(parameters.recipients)), + users = set( + await cruds_users.get_users_by_emails( + db=db, + emails=list(set(parameters.recipients)), + ), ) found_emails = [user.email for user in users] for email in parameters.recipients: diff --git a/app/core/users/schemas_users.py b/app/core/users/schemas_users.py index b39fb2e7e6..10737e447d 100644 --- a/app/core/users/schemas_users.py +++ b/app/core/users/schemas_users.py @@ -47,6 +47,9 @@ class CoreUser(CoreUserSimple): groups: "list[CoreGroupSimple]" = [] school: CoreSchool | None = None + def __hash__(self) -> int: + return hash(self.id) + class CoreUserUpdate(BaseModel): """Schema for user update""" diff --git a/tests/core/test_documents.py b/tests/core/test_documents.py index 456fb3d15a..63a68a7844 100644 --- a/tests/core/test_documents.py +++ b/tests/core/test_documents.py @@ -821,6 +821,22 @@ async def test_use_template_for_a_recipient_user_not_found( assert response.json()["errors"]["test@test.fr"] == "User not found" +async def test_use_template_for_a_recipient_duplicate( + client: TestClient, +): + response = client.post( + f"/documents/templates/{templateTeam1.id}/documents/", + json={"recipients": [user_lambda.email]}, + headers={"Authorization": f"Bearer {user_team1_token}"}, + ) + assert response.status_code == 201, response.text + assert len(response.json()["errors"]) == 1 + assert ( + response.json()["errors"][user_lambda.email] + == "Document already exists for this user" + ) + + async def test_use_template_for_a_recipient( client: TestClient, mocker: MockerFixture, @@ -840,7 +856,10 @@ async def test_use_template_for_a_recipient( ) response = client.post( f"/documents/templates/{templateTeam1.id}/documents/", - json={"recipients": [user_lambda.email]}, + json={ + "recipients": [user_lambda.email], + "allow_duplicate": True, + }, headers={"Authorization": f"Bearer {user_team1_token}"}, ) assert response.status_code == 201, response.text From e250a2ddb5aaf47017d782a7787904fdb97851a3 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Mon, 13 Jul 2026 17:56:34 +0200 Subject: [PATCH 10/13] feat: better date response and workflow --- app/core/documents/cruds_documents.py | 51 ++++++-- app/core/documents/endpoints_documents.py | 70 ++++++---- app/core/documents/models_documents.py | 7 + app/core/documents/schemas_documents.py | 18 ++- app/core/documents/utils_documents.py | 50 +++++++- app/core/memberships/utils_memberships.py | 6 +- tests/core/test_documents.py | 149 +++++++++++++++++++++- 7 files changed, 299 insertions(+), 52 deletions(-) diff --git a/app/core/documents/cruds_documents.py b/app/core/documents/cruds_documents.py index db36065be9..61e27724c0 100644 --- a/app/core/documents/cruds_documents.py +++ b/app/core/documents/cruds_documents.py @@ -8,11 +8,12 @@ from app.core.documents import models_documents, schemas_documents from app.core.documents.types_documenso import DocumentStatus from app.core.documents.utils_documents import ( - document_complete_model_to_schema, document_model_to_schema, + document_with_team_info_model_to_schema, + document_with_user_model_to_schema, team_complete_model_to_schema, team_model_to_schema, - template_complete_model_to_schema, + template_complete_with_documents_model_to_schema, template_model_to_schema, ) @@ -91,6 +92,26 @@ async def get_team_by_name( return team_model_to_schema(result) if result else None +async def get_team_by_team_id( + db: AsyncSession, + team_id: int, +) -> schemas_documents.Team | None: + """Return a team by its Documenso team id.""" + + result = ( + ( + await db.execute( + select(models_documents.DocumentTeam).where( + models_documents.DocumentTeam.team_id == team_id, + ), + ) + ) + .scalars() + .first() + ) + return team_model_to_schema(result) if result else None + + async def get_team_by_group_id( db: AsyncSession, group_id: str, @@ -173,7 +194,7 @@ async def get_team_templates( async def get_template_by_id( db: AsyncSession, template_id: UUID, -) -> schemas_documents.TemplateComplete | None: +) -> schemas_documents.TemplateCompleteWithDocuments | None: """Return a template by its internal id.""" result = ( @@ -191,13 +212,13 @@ async def get_template_by_id( .scalars() .first() ) - return template_complete_model_to_schema(result) if result else None + return template_complete_with_documents_model_to_schema(result) if result else None async def get_template_by_documenso_id( db: AsyncSession, documenso_id: int, -) -> schemas_documents.TemplateComplete | None: +) -> schemas_documents.TemplateCompleteWithDocuments | None: """Return a template by its Documenso id.""" result = ( @@ -211,7 +232,7 @@ async def get_template_by_documenso_id( .scalars() .first() ) - return template_complete_model_to_schema(result) if result else None + return template_complete_with_documents_model_to_schema(result) if result else None async def create_template( @@ -259,21 +280,29 @@ async def update_template( async def get_documents_by_user_id( db: AsyncSession, user_id: str, -) -> list[schemas_documents.Document]: +) -> list[schemas_documents.DocumentWithTeamInfo]: """Return all documents assigned to a user (without signing token)""" result = await db.execute( - select(models_documents.DocumentDocument).where( + select(models_documents.DocumentDocument) + .where( models_documents.DocumentDocument.user_id == user_id, + ) + .options( + selectinload(models_documents.DocumentDocument.template).selectinload( + models_documents.DocumentTemplate.team, + ), ), ) - return [document_model_to_schema(doc) for doc in result.scalars().all()] + return [ + document_with_team_info_model_to_schema(doc) for doc in result.scalars().all() + ] async def get_documents_by_template_id( db: AsyncSession, template_id: UUID, -) -> list[schemas_documents.DocumentComplete]: +) -> list[schemas_documents.DocumentWithUser]: """Return all documents generated from a given template""" result = await db.execute( @@ -281,7 +310,7 @@ async def get_documents_by_template_id( models_documents.DocumentDocument.template_id == template_id, ), ) - return [document_complete_model_to_schema(doc) for doc in result.scalars().all()] + return [document_with_user_model_to_schema(doc) for doc in result.scalars().all()] async def get_document_by_id( diff --git a/app/core/documents/endpoints_documents.py b/app/core/documents/endpoints_documents.py index 802372684c..47224d9549 100644 --- a/app/core/documents/endpoints_documents.py +++ b/app/core/documents/endpoints_documents.py @@ -123,23 +123,32 @@ async def create_team( detail=f"A team for the group {team_base.group_id} already exists", ) - team = schemas_documents.Team( - id=uuid.uuid4(), - team_id=team_base.team_id, - group_id=team_base.group_id, - name=team_base.name, - api_key=team_base.api_key, - ) - - documenso = _configure_documenso_api_wrapper(team, settings) + documenso = _configure_documenso_api_wrapper(team_base.api_key, settings) try: - await documenso.find_folders() + folders = await documenso.find_folders() except Exception as e: raise HTTPException( status_code=400, detail=f"Failed to connect to Documenso with the provided API key: {e}", ) - + if not folders: + raise HTTPException( + status_code=400, + detail="No folders found in Documenso for the provided API key", + ) + team_id = int(folders[0].team_id) + if await cruds_documents.get_team_by_team_id(db=db, team_id=team_id) is not None: + raise HTTPException( + status_code=400, + detail=f"A team with the Documenso team ID {team_id} already exists", + ) + team = schemas_documents.Team( + id=uuid.uuid4(), + team_id=int(team_id), + group_id=team_base.group_id, + name=team_base.name, + api_key=team_base.api_key, + ) await cruds_documents.create_team(team=team, db=db) return team @@ -186,15 +195,26 @@ async def update_team( status_code=400, detail=f"A team for the group {team_update.group_id} already exists", ) - - documenso = _configure_documenso_api_wrapper(db_team, settings) - try: - await documenso.find_folders() - except Exception as e: - raise HTTPException( - status_code=400, - detail=f"Failed to connect to Documenso with the provided API key: {e}", - ) + if team_update.api_key and team_update.api_key != db_team.api_key: + documenso = _configure_documenso_api_wrapper(team_update.api_key, settings) + try: + folders = await documenso.find_folders() + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Failed to connect to Documenso with the provided API key: {e}", + ) + if not folders: + raise HTTPException( + status_code=400, + detail="No folders found in Documenso for the provided API key", + ) + documenso_team_id = int(folders[0].team_id) + if documenso_team_id != db_team.team_id: + raise HTTPException( + status_code=400, + detail="The provided API key corresponds to a different Documenso team than the one being updated", + ) await cruds_documents.update_team(db=db, team_id=team_id, team_update=team_update) @@ -254,7 +274,7 @@ async def read_team_templates( @router.get( "/documents/templates/{template_id}", - response_model=schemas_documents.TemplateComplete, + response_model=schemas_documents.TemplateCompleteWithDocuments, status_code=200, ) async def read_template( @@ -361,7 +381,7 @@ async def use_template( detail="You do not have permission to use this template", ) - documenso = _configure_documenso_api_wrapper(db_team, settings) + documenso = _configure_documenso_api_wrapper(db_team.api_key, settings) destination_folder_id = db_template.document_directory_id if destination_folder_id is None: @@ -424,7 +444,7 @@ async def use_template( @router.get( "/documents/me/", - response_model=list[schemas_documents.Document], + response_model=list[schemas_documents.DocumentWithTeamInfo], status_code=200, ) async def read_my_documents( @@ -511,7 +531,7 @@ async def download_document_file( ): raise HTTPException(status_code=403, detail="Access forbidden") - documenso = _configure_documenso_api_wrapper(db_team, settings=settings) + documenso = _configure_documenso_api_wrapper(db_team.api_key, settings=settings) file_content = await documenso.download_document( document_id=db_document.documenso_id, ) @@ -527,7 +547,7 @@ async def download_document_file( @router.get( "/documents/templates/{template_id}/documents/", - response_model=list[schemas_documents.DocumentComplete], + response_model=list[schemas_documents.DocumentWithUser], status_code=200, ) async def read_template_documents( diff --git a/app/core/documents/models_documents.py b/app/core/documents/models_documents.py index 09113103ab..165ddc2d47 100644 --- a/app/core/documents/models_documents.py +++ b/app/core/documents/models_documents.py @@ -48,6 +48,7 @@ class DocumentTemplate(Base): "DocumentDocument", lazy="selectin", init=False, + back_populates="template", ) team: Mapped[DocumentTeam] = relationship( "DocumentTeam", @@ -76,3 +77,9 @@ class DocumentDocument(Base): lazy="joined", init=False, ) + template: Mapped[DocumentTemplate] = relationship( + "DocumentTemplate", + lazy="selectin", + init=False, + back_populates="documents", + ) diff --git a/app/core/documents/schemas_documents.py b/app/core/documents/schemas_documents.py index 899f80830c..e8dcf83b6f 100644 --- a/app/core/documents/schemas_documents.py +++ b/app/core/documents/schemas_documents.py @@ -9,7 +9,6 @@ class TeamBase(BaseModel): - team_id: int group_id: str name: str api_key: str @@ -17,6 +16,12 @@ class TeamBase(BaseModel): class Team(TeamBase): id: UUID + team_id: int + + +class TeamInfo(BaseModel): + id: UUID + name: str class TeamComplete(Team): @@ -45,10 +50,13 @@ class Template(TemplateBase): class TemplateComplete(Template): - documents: list["DocumentComplete"] team: Team +class TemplateCompleteWithDocuments(TemplateComplete): + documents: list["DocumentWithUser"] + + class TemplateUpdate(BaseModel): document_directory_id: str | None = None @@ -87,5 +95,9 @@ class DocumentWithToken(Document): signing_token: str -class DocumentComplete(Document): +class DocumentWithUser(Document): user: CoreUser + + +class DocumentWithTeamInfo(Document): + team_info: TeamInfo diff --git a/app/core/documents/utils_documents.py b/app/core/documents/utils_documents.py index c2613ed895..41cb7b9030 100644 --- a/app/core/documents/utils_documents.py +++ b/app/core/documents/utils_documents.py @@ -57,6 +57,23 @@ def template_complete_model_to_schema( ) -> schemas_documents.TemplateComplete: """Convert a DocumentTemplate model to a TemplateComplete schema.""" return schemas_documents.TemplateComplete( + id=model.id, + documenso_id=model.documenso_id, + name=model.name, + team_id=model.team_id, + created_at=model.created_at, + updated_at=model.updated_at, + deleted=model.deleted, + document_directory_id=model.document_directory_id, + team=team_model_to_schema(model.team), + ) + + +def template_complete_with_documents_model_to_schema( + model: models_documents.DocumentTemplate, +) -> schemas_documents.TemplateCompleteWithDocuments: + """Convert a DocumentTemplate model to a TemplateComplete schema.""" + return schemas_documents.TemplateCompleteWithDocuments( id=model.id, documenso_id=model.documenso_id, name=model.name, @@ -66,7 +83,7 @@ def template_complete_model_to_schema( deleted=model.deleted, document_directory_id=model.document_directory_id, documents=[ - document_complete_model_to_schema(document) for document in model.documents + document_with_user_model_to_schema(document) for document in model.documents ], team=team_model_to_schema(model.team), ) @@ -108,11 +125,11 @@ def document_model_to_schema( ) -def document_complete_model_to_schema( +def document_with_user_model_to_schema( model: models_documents.DocumentDocument, -) -> schemas_documents.DocumentComplete: +) -> schemas_documents.DocumentWithUser: """Convert a DocumentDocument model to a DocumentComplete schema.""" - return schemas_documents.DocumentComplete( + return schemas_documents.DocumentWithUser( id=model.id, documenso_id=model.documenso_id, name=model.name, @@ -133,15 +150,36 @@ def document_complete_model_to_schema( ) +def document_with_team_info_model_to_schema( + model: models_documents.DocumentDocument, +) -> schemas_documents.DocumentWithTeamInfo: + """Convert a DocumentDocument model to a DocumentWithTemplate schema.""" + return schemas_documents.DocumentWithTeamInfo( + id=model.id, + documenso_id=model.documenso_id, + name=model.name, + template_id=model.template_id, + module=model.module, + user_id=model.user_id, + status=model.status, + created_at=model.created_at, + updated_at=model.updated_at, + team_info=schemas_documents.TeamInfo( + id=model.template.team.id, + name=model.template.team.name, + ), + ) + + def _configure_documenso_api_wrapper( - team: schemas_documents.Team, + api_key: str, settings: Settings, ) -> DocumensoAPIWrapper: if settings.DOCUMENSO_URL is None: raise MissingDocumensoURLError() return DocumensoAPIWrapper( configuration=DocumensoConfiguration( - api_key=team.api_key, + api_key=api_key, documenso_url=settings.DOCUMENSO_URL, ), ) diff --git a/app/core/memberships/utils_memberships.py b/app/core/memberships/utils_memberships.py index 0b4876afaf..84d35364a4 100644 --- a/app/core/memberships/utils_memberships.py +++ b/app/core/memberships/utils_memberships.py @@ -216,7 +216,7 @@ async def add_membership_to_user( raise ElementTemplateNotFoundError(association_membership.template_id) documenso = _configure_documenso_api_wrapper( - team=template.team, + api_key=template.team.api_key, settings=settings, ) @@ -274,7 +274,7 @@ async def renew_membership_documents( return documenso = _configure_documenso_api_wrapper( - team=team, + api_key=team.api_key, settings=settings, ) @@ -323,7 +323,7 @@ async def remove_membership_from_user( if template is None: return documenso = _configure_documenso_api_wrapper( - team=template.team, + api_key=template.team.api_key, settings=settings, ) await delete_document( diff --git a/tests/core/test_documents.py b/tests/core/test_documents.py index 63a68a7844..f222876fb8 100644 --- a/tests/core/test_documents.py +++ b/tests/core/test_documents.py @@ -3,7 +3,12 @@ from uuid import UUID, uuid4 import pytest_asyncio -from documenso_sdk import DocumentDownloadResponse +from documenso_sdk import ( + DocumentDownloadResponse, + FolderFindFoldersData, + FolderFindFoldersDataType, + FolderFindFoldersVisibility, +) from fastapi.testclient import TestClient from pydantic import BaseModel from pytest_mock import MockerFixture @@ -338,18 +343,62 @@ async def test_create_team_invalid_api_key(client: TestClient): assert len(response.json()) == 2 +async def test_create_team_no_folder( + client: TestClient, + mocker: MockerFixture, +): + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.find_folders", + return_value=[], + ) + response = client.post( + "/documents/teams/", + json={ + "team_id": 6, + "group_id": group3.id, + "name": "Team 6", + "api_key": "api_key_6", + }, + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "No folders found in Documenso for the provided API key" + ) + + response = client.get( + "/documents/teams/", + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 2 + + async def test_create_team( client: TestClient, mocker: MockerFixture, ): mocker.patch( "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.find_folders", - return_value=None, + return_value=[ + FolderFindFoldersData( + id="zs", + name="aqsz", + user_id=1, + team_id=3, + parent_id=None, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + pinned=False, + visibility=FolderFindFoldersVisibility.EVERYONE, + type=FolderFindFoldersDataType.DOCUMENT, + ), + ], ) response = client.post( "/documents/teams/", json={ - "team_id": 3, "group_id": group3.id, "name": "Team 3", "api_key": "api_key_3", @@ -447,13 +496,105 @@ async def test_update_team_invalid_api_key(client: TestClient): assert team1_data["api_key"] == "api_key_1" +async def test_update_team_no_folder( + client: TestClient, + mocker: MockerFixture, +): + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.find_folders", + return_value=[], + ) + response = client.patch( + f"/documents/teams/{team1.id}", + json={ + "name": "Team 1 Updated", + "group_id": group4.id, + "api_key": "api_key_1_updated", + }, + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "No folders found in Documenso for the provided API key" + ) + + response = client.get( + "/documents/teams", + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 200 + teams = response.json() + team1_data = next((t for t in teams if t["id"] == str(team1.id)), None) + assert team1_data is not None + assert team1_data["name"] == "Team 1" + + +async def test_update_team_different_documenso_team( + client: TestClient, + mocker: MockerFixture, +): + mocker.patch( + "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.find_folders", + return_value=[ + FolderFindFoldersData( + id="zs", + name="aqsz", + user_id=1, + team_id=8, + parent_id=None, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + pinned=False, + visibility=FolderFindFoldersVisibility.EVERYONE, + type=FolderFindFoldersDataType.DOCUMENT, + ), + ], + ) + response = client.patch( + f"/documents/teams/{team1.id}", + json={ + "api_key": "api_key_1_updated", + }, + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 400, response.text + assert ( + response.json()["detail"] + == "The provided API key corresponds to a different Documenso team than the one being updated" + ) + + response = client.get( + "/documents/teams", + headers={"Authorization": f"Bearer {user_admin_token}"}, + ) + assert response.status_code == 200 + teams = response.json() + team1_data = next((t for t in teams if t["id"] == str(team1.id)), None) + assert team1_data is not None + assert team1_data["api_key"] == "api_key_1" + + async def test_update_team( client: TestClient, mocker: MockerFixture, ): mocker.patch( "app.core.documents.documenso_api_wrapper.DocumensoAPIWrapper.find_folders", - return_value=None, + return_value=[ + FolderFindFoldersData( + id="zs", + name="aqsz", + user_id=1, + team_id=1, + parent_id=None, + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + pinned=False, + visibility=FolderFindFoldersVisibility.EVERYONE, + type=FolderFindFoldersDataType.DOCUMENT, + ), + ], ) response = client.patch( f"/documents/teams/{team1.id}", From 6f21992f3d347aee9e85d073e739e1623d8f8577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 17 Jul 2026 12:55:17 +0200 Subject: [PATCH 11/13] feat: add statistics --- app/core/documents/cruds_documents.py | 130 ++++++++++++++++++++-- app/core/documents/endpoints_documents.py | 7 +- app/core/documents/schemas_documents.py | 13 ++- app/core/documents/utils_documents.py | 21 +++- tests/core/test_documents.py | 13 +++ 5 files changed, 168 insertions(+), 16 deletions(-) diff --git a/app/core/documents/cruds_documents.py b/app/core/documents/cruds_documents.py index 61e27724c0..1eb0d44e0c 100644 --- a/app/core/documents/cruds_documents.py +++ b/app/core/documents/cruds_documents.py @@ -1,9 +1,10 @@ +from collections import defaultdict from datetime import UTC, datetime from uuid import UUID -from sqlalchemy import delete, select, update +from sqlalchemy import ScalarSelect, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import noload, selectinload from app.core.documents import models_documents, schemas_documents from app.core.documents.types_documenso import DocumentStatus @@ -14,9 +15,36 @@ team_complete_model_to_schema, team_model_to_schema, template_complete_with_documents_model_to_schema, - template_model_to_schema, + template_with_statistics_model_to_schema, ) + +def _template_statistics_subqueries() -> tuple[ + ScalarSelect[int], + ScalarSelect[int], + ScalarSelect[int], + ScalarSelect[int], +]: + def base(*extra): + return ( + select(func.count(models_documents.DocumentDocument.id)) + .where( + models_documents.DocumentDocument.template_id + == models_documents.DocumentTemplate.id, + *extra, + ) + .correlate(models_documents.DocumentTemplate) + .scalar_subquery() + ) + + return ( + base(), + base(models_documents.DocumentDocument.status == DocumentStatus.COMPLETED), + base(models_documents.DocumentDocument.status == DocumentStatus.PENDING), + base(models_documents.DocumentDocument.status == DocumentStatus.REJECTED), + ) + + # region Team @@ -51,9 +79,9 @@ async def get_teams_by_group_ids( db: AsyncSession, group_ids: list[str], ) -> list[schemas_documents.TeamComplete]: - """Return a team by its internal id.""" + """Return teams by their group ids, along with their templates and statistics.""" - result = ( + teams = ( ( await db.execute( select(models_documents.DocumentTeam) @@ -61,15 +89,64 @@ async def get_teams_by_group_ids( models_documents.DocumentTeam.group_id.in_(group_ids), ) .options( - selectinload(models_documents.DocumentTeam.templates), selectinload(models_documents.DocumentTeam.group), + noload(models_documents.DocumentTeam.templates), ), ) ) .scalars() .all() ) - return [team_complete_model_to_schema(team) for team in result] + + if not teams: + return [] + + team_ids = [team.id for team in teams] + + total_sq, completed_sq, pending_sq, rejected_sq = _template_statistics_subqueries() + + templates_result = await db.execute( + select( + models_documents.DocumentTemplate, + total_sq.label("total_documents"), + completed_sq.label("total_signed_documents"), + pending_sq.label("total_pending_documents"), + rejected_sq.label("total_rejected_documents"), + ) + .where( + models_documents.DocumentTemplate.team_id.in_(team_ids), + ) + .options( + noload(models_documents.DocumentTemplate.documents), + ), + ) + + templates_by_team_id: dict[UUID, list[schemas_documents.TemplateWithStatistics]] = ( + defaultdict(list) + ) + for ( + template, + total_documents, + total_signed_documents, + total_pending_documents, + total_rejected_documents, + ) in templates_result.all(): + templates_by_team_id[template.team_id].append( + template_with_statistics_model_to_schema( + template, + schemas_documents.TemplateStatistics( + total_documents=total_documents, + total_signed_documents=total_signed_documents, + total_pending_documents=total_pending_documents, + total_rejected_documents=total_rejected_documents, + ), + ), + ) + + return [ + team_complete_model_to_schema(team, templates_by_team_id.get(team.id, [])) + for team in teams + ] async def get_team_by_name( @@ -177,18 +254,47 @@ async def delete_team(db: AsyncSession, team_id: UUID) -> None: # region Template -async def get_team_templates( +async def get_team_templates_with_statistics( db: AsyncSession, team_id: UUID, -) -> list[schemas_documents.Template]: - """Return all templates filtered by team.""" +) -> list[schemas_documents.TemplateWithStatistics]: + """Return all templates filtered by team, with document statistics.""" + + total_sq, completed_sq, pending_sq, rejected_sq = _template_statistics_subqueries() result = await db.execute( - select(models_documents.DocumentTemplate).where( + select( + models_documents.DocumentTemplate, + total_sq.label("total_documents"), + completed_sq.label("total_signed_documents"), + pending_sq.label("total_pending_documents"), + rejected_sq.label("total_rejected_documents"), + ) + .where( models_documents.DocumentTemplate.team_id == team_id, + ) + .options( + noload(models_documents.DocumentTemplate.documents), ), ) - return [template_model_to_schema(template) for template in result.scalars().all()] + return [ + template_with_statistics_model_to_schema( + template, + schemas_documents.TemplateStatistics( + total_documents=total_documents, + total_signed_documents=total_signed_documents, + total_pending_documents=total_pending_documents, + total_rejected_documents=total_rejected_documents, + ), + ) + for ( + template, + total_documents, + total_signed_documents, + total_pending_documents, + total_rejected_documents, + ) in result.all() + ] async def get_template_by_id( diff --git a/app/core/documents/endpoints_documents.py b/app/core/documents/endpoints_documents.py index 47224d9549..32eee004f3 100644 --- a/app/core/documents/endpoints_documents.py +++ b/app/core/documents/endpoints_documents.py @@ -247,7 +247,7 @@ async def delete_team( @router.get( "/documents/teams/{team_id}/templates/", - response_model=list[schemas_documents.Template], + response_model=list[schemas_documents.TemplateWithStatistics], status_code=200, ) async def read_team_templates( @@ -269,7 +269,10 @@ async def read_team_templates( detail="You do not have permission to view templates for this team", ) - return await cruds_documents.get_team_templates(db=db, team_id=team_id) + return await cruds_documents.get_team_templates_with_statistics( + db=db, + team_id=team_id, + ) @router.get( diff --git a/app/core/documents/schemas_documents.py b/app/core/documents/schemas_documents.py index e8dcf83b6f..d50c55b8b8 100644 --- a/app/core/documents/schemas_documents.py +++ b/app/core/documents/schemas_documents.py @@ -25,7 +25,7 @@ class TeamInfo(BaseModel): class TeamComplete(Team): - templates: list["Template"] + templates: list["TemplateWithStatistics"] group: CoreGroup @@ -49,6 +49,17 @@ class Template(TemplateBase): updated_at: datetime +class TemplateStatistics(BaseModel): + total_documents: int + total_signed_documents: int + total_pending_documents: int + total_rejected_documents: int + + +class TemplateWithStatistics(Template): + statistics: TemplateStatistics + + class TemplateComplete(Template): team: Team diff --git a/app/core/documents/utils_documents.py b/app/core/documents/utils_documents.py index 41cb7b9030..f879e9a80a 100644 --- a/app/core/documents/utils_documents.py +++ b/app/core/documents/utils_documents.py @@ -39,6 +39,24 @@ def template_model_to_schema( ) +def template_with_statistics_model_to_schema( + model: models_documents.DocumentTemplate, + statistics: schemas_documents.TemplateStatistics, +) -> schemas_documents.TemplateWithStatistics: + """Convert a DocumentTemplate model to a TemplateWithStatistics schema.""" + return schemas_documents.TemplateWithStatistics( + id=model.id, + documenso_id=model.documenso_id, + name=model.name, + team_id=model.team_id, + created_at=model.created_at, + updated_at=model.updated_at, + deleted=model.deleted, + document_directory_id=model.document_directory_id, + statistics=statistics, + ) + + def team_model_to_schema( model: models_documents.DocumentTeam, ) -> schemas_documents.Team: @@ -91,6 +109,7 @@ def template_complete_with_documents_model_to_schema( def team_complete_model_to_schema( model: models_documents.DocumentTeam, + templates_with_statistics: list[schemas_documents.TemplateWithStatistics], ) -> schemas_documents.TeamComplete: """Convert a DocumentTeam model to a TeamComplete schema.""" return schemas_documents.TeamComplete( @@ -99,7 +118,7 @@ def team_complete_model_to_schema( api_key=model.api_key, name=model.name, group_id=model.group_id, - templates=[template_model_to_schema(template) for template in model.templates], + templates=templates_with_statistics, group=CoreGroup( id=model.group.id, name=model.group.name, diff --git a/tests/core/test_documents.py b/tests/core/test_documents.py index f222876fb8..105f794a50 100644 --- a/tests/core/test_documents.py +++ b/tests/core/test_documents.py @@ -275,6 +275,15 @@ async def test_get_user_teams(client: TestClient): ) assert response.status_code == 200 assert len(response.json()) == 1 + template_team = response.json()[0] + assert template_team["id"] == str(team1.id) + templates = template_team["templates"] + assert len(templates) == 2 + assert templates[0]["id"] == str(templateTeam1.id) + assert templates[0]["statistics"]["total_documents"] == 6 + assert templates[0]["statistics"]["total_signed_documents"] == 1 + assert templates[0]["statistics"]["total_pending_documents"] == 4 + assert templates[0]["statistics"]["total_rejected_documents"] == 1 async def test_create_team_existing_group(client: TestClient): @@ -673,6 +682,10 @@ async def test_get_team_templates(client: TestClient): templates = response.json() assert len(templates) == 1 assert templates[0]["id"] == str(templateTeam2.id) + assert templates[0]["statistics"]["total_documents"] == 1 + assert templates[0]["statistics"]["total_signed_documents"] == 0 + assert templates[0]["statistics"]["total_pending_documents"] == 1 + assert templates[0]["statistics"]["total_rejected_documents"] == 0 async def test_get_team_templates_not_found(client: TestClient): From 7db5481e96bc6ff1b436a0ef3f3aab7770b12edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 17 Jul 2026 14:45:10 +0200 Subject: [PATCH 12/13] feat: store recipient_id --- app/core/documents/cruds_documents.py | 1 + app/core/documents/models_documents.py | 1 + app/core/documents/schemas_documents.py | 1 + app/core/documents/utils_documents.py | 7 ++- .../62-document_template_recipient.py | 47 +++++++++++++++++++ tests/core/test_documents.py | 5 ++ 6 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/62-document_template_recipient.py diff --git a/app/core/documents/cruds_documents.py b/app/core/documents/cruds_documents.py index 1eb0d44e0c..904bb8c822 100644 --- a/app/core/documents/cruds_documents.py +++ b/app/core/documents/cruds_documents.py @@ -352,6 +352,7 @@ async def create_template( id=template.id, documenso_id=template.documenso_id, name=template.name, + recipient_id=template.recipient_id, team_id=template.team_id, deleted=template.deleted, document_directory_id=template.document_directory_id, diff --git a/app/core/documents/models_documents.py b/app/core/documents/models_documents.py index 165ddc2d47..7284524971 100644 --- a/app/core/documents/models_documents.py +++ b/app/core/documents/models_documents.py @@ -38,6 +38,7 @@ class DocumentTemplate(Base): id: Mapped[PrimaryKey] documenso_id: Mapped[int] = mapped_column(unique=True) name: Mapped[str] + recipient_id: Mapped[int] team_id: Mapped[UUID] = mapped_column(ForeignKey("document_team.id")) created_at: Mapped[datetime] updated_at: Mapped[datetime] diff --git a/app/core/documents/schemas_documents.py b/app/core/documents/schemas_documents.py index d50c55b8b8..a8c32339fa 100644 --- a/app/core/documents/schemas_documents.py +++ b/app/core/documents/schemas_documents.py @@ -38,6 +38,7 @@ class TeamUpdate(BaseModel): class TemplateBase(BaseModel): documenso_id: int name: str + recipient_id: int team_id: UUID diff --git a/app/core/documents/utils_documents.py b/app/core/documents/utils_documents.py index f879e9a80a..e3944a1cef 100644 --- a/app/core/documents/utils_documents.py +++ b/app/core/documents/utils_documents.py @@ -31,6 +31,7 @@ def template_model_to_schema( id=model.id, documenso_id=model.documenso_id, name=model.name, + recipient_id=model.recipient_id, team_id=model.team_id, created_at=model.created_at, updated_at=model.updated_at, @@ -48,6 +49,7 @@ def template_with_statistics_model_to_schema( id=model.id, documenso_id=model.documenso_id, name=model.name, + recipient_id=model.recipient_id, team_id=model.team_id, created_at=model.created_at, updated_at=model.updated_at, @@ -78,6 +80,7 @@ def template_complete_model_to_schema( id=model.id, documenso_id=model.documenso_id, name=model.name, + recipient_id=model.recipient_id, team_id=model.team_id, created_at=model.created_at, updated_at=model.updated_at, @@ -95,6 +98,7 @@ def template_complete_with_documents_model_to_schema( id=model.id, documenso_id=model.documenso_id, name=model.name, + recipient_id=model.recipient_id, team_id=model.team_id, created_at=model.created_at, updated_at=model.updated_at, @@ -225,6 +229,7 @@ async def handle_template_creation_webhook( id=uuid.uuid4(), documenso_id=payload.id, name=payload.title, + recipient_id=payload.recipients[0].id, team_id=owning_team.id, deleted=False, document_directory_id=None, @@ -253,7 +258,7 @@ async def use_template_for_user( external_id=document_id, recipients=[ TemplateCreateDocumentFromTemplateRecipientRequest( - id=1, + id=template.recipient_id, name=f"{user.firstname} {user.name}", email=user.email, ), diff --git a/migrations/versions/62-document_template_recipient.py b/migrations/versions/62-document_template_recipient.py new file mode 100644 index 0000000000..09b7f87f9a --- /dev/null +++ b/migrations/versions/62-document_template_recipient.py @@ -0,0 +1,47 @@ +"""UnregisteredRecoveryRequests + +Create Date: 2026-05-30 14:03:43.757430 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ca69342eac58" +down_revision: str | None = "21ee852eac58" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + op.add_column( + "document_template", + sa.Column("recipient_id", sa.Integer(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("document_template", "recipient_id") + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/core/test_documents.py b/tests/core/test_documents.py index 105f794a50..faf1b3686b 100644 --- a/tests/core/test_documents.py +++ b/tests/core/test_documents.py @@ -110,6 +110,7 @@ async def init_objects() -> None: id=uuid4(), documenso_id=1, name="Template 1", + recipient_id=1, team_id=team1.id, created_at=datetime(2023, 1, 1, tzinfo=UTC), updated_at=datetime(2023, 1, 2, tzinfo=UTC), @@ -122,6 +123,7 @@ async def init_objects() -> None: id=uuid4(), documenso_id=2, name="Template 2", + recipient_id=2, team_id=team2.id, created_at=datetime(2023, 1, 3, tzinfo=UTC), updated_at=datetime(2023, 1, 4, tzinfo=UTC), @@ -133,6 +135,7 @@ async def init_objects() -> None: id=uuid4(), documenso_id=3, name="Template Deleted", + recipient_id=3, team_id=team1.id, created_at=datetime(2023, 1, 5, tzinfo=UTC), updated_at=datetime(2023, 1, 6, tzinfo=UTC), @@ -1276,6 +1279,7 @@ async def test_webhook_template_update( id=uuid4(), documenso_id=11, name="My Template", + recipient_id=4, team_id=team1.id, created_at=datetime(2023, 1, 1, tzinfo=UTC), updated_at=datetime(2023, 1, 2, tzinfo=UTC), @@ -1365,6 +1369,7 @@ async def test_webhook_template_deletion( template = DocumentTemplate( id=uuid4(), documenso_id=12, + recipient_id=4, name="My Template", team_id=team1.id, created_at=datetime(2023, 1, 1, tzinfo=UTC), From cb133218166f5788ee6a8eb24cebc456d480205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 17 Jul 2026 15:04:28 +0200 Subject: [PATCH 13/13] fix: tests --- tests/core/test_memberships.py | 1 + tests/modules/cdr/test_cdr.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/core/test_memberships.py b/tests/core/test_memberships.py index f49938a7e1..55f63fcb5a 100644 --- a/tests/core/test_memberships.py +++ b/tests/core/test_memberships.py @@ -96,6 +96,7 @@ async def init_objects(): id=uuid.uuid4(), documenso_id=1, name="Template", + recipient_id=1, team_id=team.id, created_at=datetime.now(UTC), updated_at=datetime.now(UTC), diff --git a/tests/modules/cdr/test_cdr.py b/tests/modules/cdr/test_cdr.py index 3abdf43319..96adcd0118 100644 --- a/tests/modules/cdr/test_cdr.py +++ b/tests/modules/cdr/test_cdr.py @@ -368,6 +368,7 @@ async def init_objects(): id=uuid.uuid4(), documenso_id=1, name="Template", + recipient_id=1, team_id=document_team.id, created_at=datetime.now(UTC), updated_at=datetime.now(UTC),