From 2471c00780aaef3b6cad4bb20792c1faf5fbff95 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 00:02:17 +0200 Subject: [PATCH 1/6] feat: use modular association groupements --- app/modules/phonebook/cruds_phonebook.py | 411 ++++++++++++++---- app/modules/phonebook/endpoints_phonebook.py | 185 ++++++-- app/modules/phonebook/models_phonebook.py | 16 +- app/modules/phonebook/schemas_phonebook.py | 21 +- app/modules/phonebook/types_phonebook.py | 9 - .../versions/35_phonebook-groupement.py | 199 +++++++++ tests/test_phonebook.py | 203 ++++++++- 7 files changed, 900 insertions(+), 144 deletions(-) create mode 100644 migrations/versions/35_phonebook-groupement.py diff --git a/app/modules/phonebook/cruds_phonebook.py b/app/modules/phonebook/cruds_phonebook.py index bc7b50a35f..f27c654630 100644 --- a/app/modules/phonebook/cruds_phonebook.py +++ b/app/modules/phonebook/cruds_phonebook.py @@ -1,7 +1,9 @@ from collections.abc import Sequence +from uuid import UUID from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.core.users import models_users from app.modules.phonebook import models_phonebook, schemas_phonebook, types_phonebook @@ -36,11 +38,26 @@ async def is_user_president( # ---------------------------------------------------------------------------- # async def get_all_associations( db: AsyncSession, -) -> Sequence[models_phonebook.Association]: +) -> Sequence[schemas_phonebook.AssociationComplete]: """Return all Associations from database""" - result = await db.execute(select(models_phonebook.Association)) - return result.scalars().all() + result = await db.execute( + select(models_phonebook.Association).options( + selectinload(models_phonebook.Association.associated_groups), + ), + ) + return [ + schemas_phonebook.AssociationComplete( + id=association.id, + name=association.name, + description=association.description, + groupement_id=association.groupement_id, + mandate_year=association.mandate_year, + deactivated=association.deactivated, + associated_groups=[group.id for group in association.associated_groups], + ) + for association in result.scalars().all() + ] async def get_all_role_tags() -> Sequence[str]: @@ -49,38 +66,148 @@ async def get_all_role_tags() -> Sequence[str]: return [tag.value for tag in types_phonebook.RoleTags] -async def get_all_kinds() -> Sequence[str]: - """Return all Kinds from Enum""" +async def get_all_groupements( + db: AsyncSession, +) -> Sequence[schemas_phonebook.AssociationGroupement]: + """Return all Groupements from database""" - return [kind.value for kind in types_phonebook.Kinds] + result = await db.execute( + select(models_phonebook.AssociationGroupement).order_by( + models_phonebook.AssociationGroupement.name, + ), + ) + return [ + schemas_phonebook.AssociationGroupement( + id=groupement.id, + name=groupement.name, + ) + for groupement in result.scalars().all() + ] -async def get_all_memberships( - mandate_year: int, +# ---------------------------------------------------------------------------- # +# Groupement # +# ---------------------------------------------------------------------------- # + + +async def get_groupement_by_id( + groupement_id: UUID, db: AsyncSession, -) -> Sequence[models_phonebook.Membership]: - """Return all Memberships from database""" +) -> schemas_phonebook.AssociationGroupement | None: + """Return Groupement with id from database""" + + result = ( + ( + await db.execute( + select(models_phonebook.AssociationGroupement).where( + models_phonebook.AssociationGroupement.id == groupement_id, + ), + ) + ) + .scalars() + .first() + ) + return ( + schemas_phonebook.AssociationGroupement( + id=result.id, + name=result.name, + ) + if result + else None + ) - result = await db.execute( - select(models_phonebook.Membership).where( - models_phonebook.Membership.mandate_year == mandate_year, + +async def get_groupement_by_name( + groupement_name: str, + db: AsyncSession, +) -> schemas_phonebook.AssociationGroupement | None: + """Return Groupement with name from database""" + + result = ( + ( + await db.execute( + select(models_phonebook.AssociationGroupement).where( + models_phonebook.AssociationGroupement.name == groupement_name, + ), + ) + ) + .scalars() + .first() + ) + return ( + schemas_phonebook.AssociationGroupement( + id=result.id, + name=result.name, + ) + if result + else None + ) + + +async def create_groupement( + groupement: schemas_phonebook.AssociationGroupement, + db: AsyncSession, +) -> None: + """Create a new Groupement in database and return it""" + + db.add( + models_phonebook.AssociationGroupement( + id=groupement.id, + name=groupement.name, ), ) - return result.scalars().all() + await db.flush() + + +async def update_groupement( + groupement_id: UUID, + groupement_edit: schemas_phonebook.AssociationGroupementBase, + db: AsyncSession, +) -> None: + """Update a Groupement in database""" + + await db.execute( + update(models_phonebook.AssociationGroupement) + .where(models_phonebook.AssociationGroupement.id == groupement_id) + .values(**groupement_edit.model_dump(exclude_none=True)), + ) + await db.flush() + + +async def delete_groupement( + groupement_id: UUID, + db: AsyncSession, +) -> None: + """Delete a Groupement from database""" + + await db.execute( + delete(models_phonebook.AssociationGroupement).where( + models_phonebook.AssociationGroupement.id == groupement_id, + ), + ) + await db.flush() # ---------------------------------------------------------------------------- # # Association # # ---------------------------------------------------------------------------- # async def create_association( - association: models_phonebook.Association, + association: schemas_phonebook.AssociationComplete, db: AsyncSession, -) -> models_phonebook.Association: +) -> None: """Create a new Association in database and return it""" - db.add(association) + db.add( + models_phonebook.Association( + id=association.id, + name=association.name, + description=association.description, + groupement_id=association.groupement_id, + mandate_year=association.mandate_year, + deactivated=association.deactivated, + ), + ) await db.flush() - return association async def update_association( @@ -157,38 +284,87 @@ async def delete_association(association_id: str, db: AsyncSession): async def get_association_by_id( association_id: str, db: AsyncSession, -) -> models_phonebook.Association | None: +) -> schemas_phonebook.AssociationComplete | None: """Return Association with id from database""" - result = await db.execute( - select(models_phonebook.Association).where( - models_phonebook.Association.id == association_id, - ), + result = ( + ( + await db.execute( + select(models_phonebook.Association) + .where( + models_phonebook.Association.id == association_id, + ) + .options(selectinload(models_phonebook.Association.associated_groups)), + ) + ) + .scalars() + .first() + ) + return ( + schemas_phonebook.AssociationComplete( + id=result.id, + name=result.name, + description=result.description, + groupement_id=result.groupement_id, + mandate_year=result.mandate_year, + deactivated=result.deactivated, + associated_groups=[group.id for group in result.associated_groups], + ) + if result + else None ) - return result.scalars().first() -async def get_associated_groups_by_association_id( - association_id: str, +async def get_associations_by_groupement_id( + groupement_id: UUID, db: AsyncSession, -) -> Sequence[models_phonebook.AssociationAssociatedGroups]: - """Return all AssociatedGroups with association_id from database""" - - result = await db.execute( - select(models_phonebook.AssociationAssociatedGroups).where( - models_phonebook.AssociationAssociatedGroups.association_id - == association_id, - ), +) -> Sequence[schemas_phonebook.AssociationComplete]: + """Return all Associations with groupement_id from database""" + + result = ( + ( + await db.execute( + select(models_phonebook.Association).where( + models_phonebook.Association.groupement_id == groupement_id, + ), + ) + ) + .scalars() + .all() ) - return result.scalars().all() + return [ + schemas_phonebook.AssociationComplete( + id=association.id, + name=association.name, + description=association.description, + groupement_id=association.groupement_id, + mandate_year=association.mandate_year, + deactivated=association.deactivated, + associated_groups=[], + ) + for association in result + ] # ---------------------------------------------------------------------------- # # Membership # # ---------------------------------------------------------------------------- # -async def create_membership(membership: models_phonebook.Membership, db: AsyncSession): +async def create_membership( + membership: schemas_phonebook.MembershipComplete, + db: AsyncSession, +): """Create a Membership in database""" - db.add(membership) + db.add( + models_phonebook.Membership( + id=membership.id, + user_id=membership.user_id, + association_id=membership.association_id, + mandate_year=membership.mandate_year, + role_name=membership.role_name, + role_tags=membership.role_tags or "", + member_order=membership.member_order, + ), + ) await db.flush() @@ -268,47 +444,98 @@ async def delete_membership(membership_id: str, db: AsyncSession): async def get_memberships_by_user_id( user_id: str, db: AsyncSession, -) -> Sequence[models_phonebook.Membership]: +) -> Sequence[schemas_phonebook.MembershipComplete]: """Return all Memberships with user_id from database""" - result = await db.execute( - select(models_phonebook.Membership).where( - models_phonebook.Membership.user_id == user_id, - ), + result = ( + ( + await db.execute( + select(models_phonebook.Membership).where( + models_phonebook.Membership.user_id == user_id, + ), + ) + ) + .scalars() + .all() ) - return result.scalars().all() + return [ + schemas_phonebook.MembershipComplete( + id=membership.id, + user_id=membership.user_id, + association_id=membership.association_id, + mandate_year=membership.mandate_year, + role_name=membership.role_name, + role_tags=membership.role_tags, + member_order=membership.member_order, + ) + for membership in result + ] async def get_memberships_by_association_id( association_id: str, db: AsyncSession, -) -> Sequence[models_phonebook.Membership]: +) -> Sequence[schemas_phonebook.MembershipComplete]: """Return all Memberships with association_id from database""" - result = await db.execute( - select(models_phonebook.Membership) - .where( - models_phonebook.Membership.association_id == association_id, + result = ( + ( + await db.execute( + select(models_phonebook.Membership) + .where( + models_phonebook.Membership.association_id == association_id, + ) + .order_by(models_phonebook.Membership.member_order), + ) ) - .order_by(models_phonebook.Membership.member_order), + .scalars() + .all() ) - return result.scalars().all() + return [ + schemas_phonebook.MembershipComplete( + id=membership.id, + user_id=membership.user_id, + association_id=membership.association_id, + mandate_year=membership.mandate_year, + role_name=membership.role_name, + role_tags=membership.role_tags, + member_order=membership.member_order, + ) + for membership in result + ] async def get_memberships_by_association_id_and_mandate_year( association_id: str, mandate_year: int, db: AsyncSession, -) -> Sequence[models_phonebook.Membership]: +) -> Sequence[schemas_phonebook.MembershipComplete]: """Return all Memberships with association_id and mandate_year from database""" - result = await db.execute( - select(models_phonebook.Membership) - .where( - models_phonebook.Membership.association_id == association_id, - models_phonebook.Membership.mandate_year == mandate_year, + result = ( + ( + await db.execute( + select(models_phonebook.Membership) + .where( + models_phonebook.Membership.association_id == association_id, + models_phonebook.Membership.mandate_year == mandate_year, + ) + .order_by(models_phonebook.Membership.member_order), + ) ) - .order_by(models_phonebook.Membership.member_order), + .scalars() + .all() ) - return result.scalars().all() + return [ + schemas_phonebook.MembershipComplete( + id=membership.id, + user_id=membership.user_id, + association_id=membership.association_id, + mandate_year=membership.mandate_year, + role_name=membership.role_name, + role_tags=membership.role_tags, + member_order=membership.member_order, + ) + for membership in result + ] async def get_membership_by_association_id_user_id_and_mandate_year( @@ -316,28 +543,64 @@ async def get_membership_by_association_id_user_id_and_mandate_year( user_id: str, mandate_year: int, db: AsyncSession, -) -> models_phonebook.Membership | None: +) -> schemas_phonebook.MembershipComplete | None: """Return all Memberships with association_id user_id and_mandate_year from database""" - result = await db.execute( - select(models_phonebook.Membership).where( - models_phonebook.Membership.association_id == association_id, - models_phonebook.Membership.user_id == user_id, - models_phonebook.Membership.mandate_year == mandate_year, - ), + result = ( + ( + await db.execute( + select(models_phonebook.Membership).where( + models_phonebook.Membership.association_id == association_id, + models_phonebook.Membership.user_id == user_id, + models_phonebook.Membership.mandate_year == mandate_year, + ), + ) + ) + .scalars() + .first() + ) + return ( + schemas_phonebook.MembershipComplete( + id=result.id, + user_id=result.user_id, + association_id=result.association_id, + mandate_year=result.mandate_year, + role_name=result.role_name, + role_tags=result.role_tags, + member_order=result.member_order, + ) + if result + else None ) - return result.scalars().unique().first() async def get_membership_by_id( membership_id: str, db: AsyncSession, -) -> models_phonebook.Membership | None: - """Return the Membership with id from database""" - - result = await db.execute( - select(models_phonebook.Membership).where( - models_phonebook.Membership.id == membership_id, - ), +) -> schemas_phonebook.MembershipComplete | None: + """Return Membership with id from database""" + + result = ( + ( + await db.execute( + select(models_phonebook.Membership).where( + models_phonebook.Membership.id == membership_id, + ), + ) + ) + .scalars() + .first() + ) + return ( + schemas_phonebook.MembershipComplete( + id=result.id, + user_id=result.user_id, + association_id=result.association_id, + mandate_year=result.mandate_year, + role_name=result.role_name, + role_tags=result.role_tags, + member_order=result.member_order, + ) + if result + else None ) - return result.scalars().first() diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index f4406569ed..730785fdc2 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -13,11 +13,7 @@ is_user_an_ecl_member, is_user_in, ) -from app.modules.phonebook import ( - cruds_phonebook, - models_phonebook, - schemas_phonebook, -) +from app.modules.phonebook import cruds_phonebook, schemas_phonebook from app.modules.phonebook.factory_phonebook import PhonebookFactory from app.modules.phonebook.types_phonebook import RoleTags from app.types import standard_responses @@ -51,19 +47,7 @@ async def get_all_associations( """ Return all associations from database as a list of AssociationComplete schemas """ - associations = await cruds_phonebook.get_all_associations(db) - return [ - schemas_phonebook.AssociationComplete( - id=association.id, - name=association.name, - kind=association.kind, - mandate_year=association.mandate_year, - description=association.description, - associated_groups=[group.id for group in association.associated_groups], - deactivated=association.deactivated, - ) - for association in associations - ] + return await cruds_phonebook.get_all_associations(db) @module.router.get( @@ -72,7 +56,6 @@ async def get_all_associations( status_code=200, ) async def get_all_role_tags( - db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user_an_ecl_member), ): """ @@ -83,18 +66,142 @@ async def get_all_role_tags( @module.router.get( - "/phonebook/associations/kinds", - response_model=schemas_phonebook.KindsReturn, + "/phonebook/groupements", + response_model=list[schemas_phonebook.AssociationGroupement], status_code=200, ) async def get_all_kinds( user: models_users.CoreUser = Depends(is_user_an_ecl_member), + db: AsyncSession = Depends(get_db), ): """ Return all available kinds of from Kinds enum. """ - kinds = await cruds_phonebook.get_all_kinds() - return schemas_phonebook.KindsReturn(kinds=kinds) + return await cruds_phonebook.get_all_groupements(db) + + +@module.router.post( + "/phonebook/groupements", + response_model=schemas_phonebook.AssociationGroupement, + status_code=201, +) +async def create_groupement( + groupement_base: schemas_phonebook.AssociationGroupementBase, + user: models_users.CoreUser = Depends(is_user_an_ecl_member), + db: AsyncSession = Depends(get_db), +): + if not is_user_member_of_any_group( + user=user, + allowed_groups=[GroupType.CAA, GroupType.BDE], + ): + raise HTTPException( + status_code=403, + detail="You are not allowed to create association", + ) + groupement_db = await cruds_phonebook.get_groupement_by_name( + groupement_name=groupement_base.name, + db=db, + ) + if groupement_db is not None: + raise HTTPException( + status_code=400, + detail=f"Groupement with name {groupement_base.name} already exists.", + ) + + groupement_id = str(uuid.uuid4()) + groupement = schemas_phonebook.AssociationGroupement( + id=groupement_id, + name=groupement_base.name, + ) + await cruds_phonebook.create_groupement( + groupement=groupement, + db=db, + ) + return groupement + + +@module.router.patch( + "/phonebook/groupements/{groupement_id}", + status_code=204, +) +async def update_groupement( + groupement_id: uuid.UUID, + groupement_edit: schemas_phonebook.AssociationGroupementBase, + user: models_users.CoreUser = Depends(is_user_an_ecl_member), + db: AsyncSession = Depends(get_db), +): + """ + Update a groupement + + **This endpoint is only usable by CAA and BDE** + """ + if not is_user_member_of_any_group( + user=user, + allowed_groups=[GroupType.CAA, GroupType.BDE], + ): + raise HTTPException( + status_code=403, + detail=f"You are not allowed to update groupement {groupement_id}", + ) + groupement = await cruds_phonebook.get_groupement_by_id( + groupement_id=groupement_id, + db=db, + ) + if groupement is None: + raise HTTPException( + status_code=404, + detail="Groupement not found.", + ) + if groupement.name != groupement_edit.name: + existing_groupement = await cruds_phonebook.get_groupement_by_name( + groupement_name=groupement_edit.name, + db=db, + ) + if existing_groupement is not None: + raise HTTPException( + status_code=400, + detail=f"Groupement with name {groupement_edit.name} already exists.", + ) + + await cruds_phonebook.update_groupement( + groupement_id=groupement_id, + groupement_edit=groupement_edit, + db=db, + ) + + +@module.router.delete( + "/phonebook/groupements/{groupement_id}", + status_code=204, +) +async def delete_groupement( + groupement_id: uuid.UUID, + user: models_users.CoreUser = Depends(is_user_an_ecl_member), + db: AsyncSession = Depends(get_db), +): + """ + Delete a groupement + + **This endpoint is only usable by CAA and BDE** + """ + if not is_user_member_of_any_group( + user=user, + allowed_groups=[GroupType.CAA, GroupType.BDE], + ): + raise HTTPException( + status_code=403, + detail=f"You are not allowed to delete groupement {groupement_id}", + ) + associations = await cruds_phonebook.get_associations_by_groupement_id( + groupement_id=groupement_id, + db=db, + ) + if associations: + raise HTTPException( + status_code=400, + detail="You cannot delete a groupement that has associations linked to it.", + ) + await cruds_phonebook.delete_groupement(groupement_id, db) @module.router.post( @@ -123,11 +230,11 @@ async def create_association( ) association_id = str(uuid.uuid4()) - association_model = models_phonebook.Association( + association_model = schemas_phonebook.AssociationComplete( id=association_id, name=association.name, description=association.description, - kind=association.kind, + groupement_id=association.groupement_id, mandate_year=association.mandate_year, deactivated=association.deactivated, ) @@ -177,14 +284,11 @@ async def update_association( detail=f"You are not allowed to update association {association_id}", ) - try: - await cruds_phonebook.update_association( - association_id=association_id, - association_edit=association_edit, - db=db, - ) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) + await cruds_phonebook.update_association( + association_id=association_id, + association_edit=association_edit, + db=db, + ) @module.router.patch( @@ -454,20 +558,25 @@ async def create_membership( ) membershipId = str(uuid.uuid4()) - membership_model = models_phonebook.Membership( + membership_model = schemas_phonebook.MembershipComplete( id=membershipId, - **membership.model_dump(), + user_id=membership.user_id, + association_id=membership.association_id, + mandate_year=membership.mandate_year, + role_name=membership.role_name, + role_tags=membership.role_tags, + member_order=membership.member_order, ) await cruds_phonebook.create_membership(membership_model, db) user_groups_id = [group.id for group in user_added.groups] - for associated_group in association.associated_groups: - if associated_group.id not in user_groups_id: + for associated_group_id in association.associated_groups: + if associated_group_id not in user_groups_id: await cruds_groups.create_membership( models_groups.CoreMembership( user_id=membership.user_id, - group_id=associated_group.id, + group_id=associated_group_id, description=None, ), db, diff --git a/app/modules/phonebook/models_phonebook.py b/app/modules/phonebook/models_phonebook.py index 52ccc670f7..7b69a3df6c 100644 --- a/app/modules/phonebook/models_phonebook.py +++ b/app/modules/phonebook/models_phonebook.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING +from uuid import UUID from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.modules.phonebook.types_phonebook import Kinds -from app.types.sqlalchemy import Base +from app.types.sqlalchemy import Base, PrimaryKey if TYPE_CHECKING: from app.core.groups.models_groups import CoreGroup @@ -31,13 +31,23 @@ class Membership(Base): member_order: Mapped[int] +class AssociationGroupement(Base): + __tablename__ = "phonebook_association_groupement" + + id: Mapped[PrimaryKey] + name: Mapped[str] = mapped_column(index=True, unique=True) + + class Association(Base): __tablename__ = "phonebook_association" id: Mapped[str] = mapped_column(primary_key=True, index=True) name: Mapped[str] = mapped_column(index=True) description: Mapped[str | None] - kind: Mapped[Kinds] + groupement_id: Mapped[UUID] = mapped_column( + ForeignKey("phonebook_association_groupement.id"), + index=True, + ) mandate_year: Mapped[int] deactivated: Mapped[bool] associated_groups: Mapped[list["CoreGroup"]] = relationship( diff --git a/app/modules/phonebook/schemas_phonebook.py b/app/modules/phonebook/schemas_phonebook.py index 1a10adcbfd..8f4159038a 100644 --- a/app/modules/phonebook/schemas_phonebook.py +++ b/app/modules/phonebook/schemas_phonebook.py @@ -1,7 +1,8 @@ +from uuid import UUID + from pydantic import BaseModel, ConfigDict from app.core.users.schemas_users import CoreUserSimple -from app.modules.phonebook.types_phonebook import Kinds class RoleTagsReturn(BaseModel): @@ -19,7 +20,7 @@ class RoleTagsBase(BaseModel): class AssociationBase(BaseModel): name: str - kind: Kinds + groupement_id: UUID mandate_year: int description: str | None = None associated_groups: list[str] = [] # Should be a list of ids @@ -34,7 +35,7 @@ class AssociationComplete(AssociationBase): class AssociationEdit(BaseModel): name: str | None = None - kind: Kinds | None = None + groupement_id: UUID | None = None description: str | None = None mandate_year: int | None = None @@ -48,7 +49,7 @@ class MembershipBase(BaseModel): association_id: str mandate_year: int role_name: str - role_tags: str | None = None # "roletag1;roletag2;..." + role_tags: str = "" # "roletag1;roletag2;..." member_order: int model_config = ConfigDict(from_attributes=True) @@ -80,5 +81,13 @@ class MemberComplete(MemberBase): model_config = ConfigDict(from_attributes=True) -class KindsReturn(BaseModel): - kinds: list[Kinds] +class AssociationGroupementBase(BaseModel): + name: str + + model_config = ConfigDict(from_attributes=True) + + +class AssociationGroupement(AssociationGroupementBase): + id: UUID + + model_config = ConfigDict(from_attributes=True) diff --git a/app/modules/phonebook/types_phonebook.py b/app/modules/phonebook/types_phonebook.py index 0a472a872b..67cd1358ca 100644 --- a/app/modules/phonebook/types_phonebook.py +++ b/app/modules/phonebook/types_phonebook.py @@ -7,12 +7,3 @@ class RoleTags(Enum): treso = "Trez'" resp_co = "Respo Com'" resp_part = "Respo Partenariats" - - -class Kinds(Enum): - comity = "Comité" - section_ae = "Section AE" - club_ae = "Club AE" - section_use = "Section USE" - club_use = "Club USE" - association_independant = "Asso indé" diff --git a/migrations/versions/35_phonebook-groupement.py b/migrations/versions/35_phonebook-groupement.py new file mode 100644 index 0000000000..f936833612 --- /dev/null +++ b/migrations/versions/35_phonebook-groupement.py @@ -0,0 +1,199 @@ +"""phonebook + +Create Date: 2025-06-26 01:04:23.300580 +""" + +from collections.abc import Sequence +from enum import Enum +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "e81453aa7341" +down_revision: str | None = "d14266761430" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class Kinds(Enum): + comity = "Comité" + section_ae = "Section AE" + club_ae = "Club AE" + section_use = "Section USE" + club_use = "Club USE" + association_independant = "Asso indé" + + +groupement_table = sa.Table( + "phonebook_association_groupement", + sa.MetaData(), + sa.Column("id", sa.UUID(), primary_key=True), + sa.Column("name", sa.String(), nullable=False), +) + +association_table = sa.Table( + "phonebook_association", + sa.MetaData(), + sa.Column("id", sa.String(), primary_key=True, index=True), + sa.Column("name", sa.String(), index=True, nullable=False), + sa.Column("kind", sa.Enum(Kinds), nullable=False), + sa.Column("groupement_id", sa.UUID(), nullable=True), +) + +kind_ids = [ + UUID("9943fcec-464d-4d72-8ab1-f8bdf0b1f589"), + UUID("d9d79f17-3758-499d-8cae-7a8de13629b7"), + UUID("11ce0837-b3d0-419d-9716-ea4b6af9c149"), + UUID("22535f41-4a38-4c01-9747-7a34a93b0232"), + UUID("0871f672-f4ff-42e5-9e3f-570b1f10e59f"), + UUID("2410624f-e659-44e8-9fbd-6fd2e961333c"), +] + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "phonebook_association_groupement", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_phonebook_association_groupement_name"), + "phonebook_association_groupement", + ["name"], + unique=True, + ) + for i, kind in enumerate(Kinds): + op.execute( + sa.insert(groupement_table).values( + {"id": kind_ids[i], "name": kind.value}, + ), + ) + op.add_column( + "phonebook_association", + sa.Column( + "groupement_id", + sa.Uuid(), + nullable=False, + server_default=str(kind_ids[0]), + ), + ) + op.create_index( + op.f("ix_phonebook_association_groupement_id"), + "phonebook_association", + ["groupement_id"], + unique=False, + ) + op.create_foreign_key( + "fk_phonebook_association_groupement_id", + "phonebook_association", + "phonebook_association_groupement", + ["groupement_id"], + ["id"], + ) + for i, kind in enumerate(Kinds): + op.execute( + sa.update(association_table) + .where(association_table.c.kind == kind.name) + .values({"groupement_id": kind_ids[i]}), + ) + op.drop_column("phonebook_association", "kind") + sa.Enum(Kinds).drop(op.get_bind()) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum(Kinds, name="kinds").create(op.get_bind()) + op.add_column( + "phonebook_association", + sa.Column( + "kind", + postgresql.ENUM( + Kinds, + name="kinds", + ), + server_default=Kinds.comity.name, + nullable=False, + ), + ) + for i, kind in enumerate(Kinds): + op.execute( + sa.update(association_table) + .where(association_table.c.groupement_id == kind_ids[i]) + .values({"kind": kind.name}), + ) + op.drop_constraint( + "fk_phonebook_association_groupement_id", + "phonebook_association", + type_="foreignkey", + ) + op.drop_index( + op.f("ix_phonebook_association_groupement_id"), + table_name="phonebook_association", + ) + op.drop_column("phonebook_association", "groupement_id") + op.drop_index( + op.f("ix_phonebook_association_groupement_name"), + table_name="phonebook_association_groupement", + ) + op.drop_table("phonebook_association_groupement") + # ### end Alembic commands ### + + +association_ids = [str(uuid4()) for _ in range(len(Kinds))] + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + # Create the association table with the new groupement_id column + for i, kind in enumerate(Kinds): + alembic_runner.insert_into( + "phonebook_association", + { + "id": association_ids[i], + "name": kind.value, + "kind": kind.name, + "mandate_year": 2025, + "description": None, + "deactivated": False, + }, + ) + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + # Check that the groupement table has been created and populated + result = alembic_connection.execute( + sa.select(groupement_table.c.id, groupement_table.c.name), + ) + groupements = {row[1]: row[0] for row in result} + assert len(groupements) == len(Kinds) + for i, kind in enumerate(Kinds): + assert groupements[kind.value] == kind_ids[i] + + result = alembic_connection.execute( + sa.select( + association_table.c.id, + association_table.c.groupement_id, + ), + ) + result_asso = [(row[0], row[1]) for row in result if row[0] in association_ids] + for row in result_asso: + kind = list(Kinds)[association_ids.index(row[0])] + assert row[1] == groupements[kind.value], ( + f"Expected groupement_id for {kind.value} to be {groupements[kind.value]}, " + f"but got {row[1]}" + ) diff --git a/tests/test_phonebook.py b/tests/test_phonebook.py index acb5a5c47b..8ad8aa77ae 100644 --- a/tests/test_phonebook.py +++ b/tests/test_phonebook.py @@ -7,13 +7,17 @@ from app.core.groups.groups_type import GroupType from app.core.users import models_users from app.modules.phonebook import models_phonebook -from app.modules.phonebook.types_phonebook import Kinds, RoleTags +from app.modules.phonebook.types_phonebook import RoleTags from tests.commons import ( add_object_to_db, create_api_access_token, create_user_with_groups, ) +section_ae: models_phonebook.AssociationGroupement +association_independant: models_phonebook.AssociationGroupement +club_ae: models_phonebook.AssociationGroupement + association1: models_phonebook.Association association2: models_phonebook.Association association3: models_phonebook.Association @@ -53,6 +57,10 @@ async def init_objects(): global token_simple global token_admin + global section_ae + global association_independant + global club_ae + global association1 global association2 global association3 @@ -106,9 +114,25 @@ async def init_objects(): description="description", ) + section_ae = models_phonebook.AssociationGroupement( + id=uuid.uuid4(), + name="Section AE", + ) + association_independant = models_phonebook.AssociationGroupement( + id=uuid.uuid4(), + name="Association Indépendante", + ) + club_ae = models_phonebook.AssociationGroupement( + id=uuid.uuid4(), + name="Club AE", + ) + await add_object_to_db(section_ae) + await add_object_to_db(association_independant) + await add_object_to_db(club_ae) + association1 = models_phonebook.Association( id=str(uuid.uuid4()), - kind=Kinds.section_ae, + groupement_id=section_ae.id, name="ECLAIR", mandate_year=2023, deactivated=False, @@ -117,7 +141,7 @@ async def init_objects(): association2 = models_phonebook.Association( id=str(uuid.uuid4()), - kind=Kinds.association_independant, + groupement_id=association_independant.id, name="Nom", mandate_year=2023, deactivated=False, @@ -126,7 +150,7 @@ async def init_objects(): association3 = models_phonebook.Association( id=str(uuid.uuid4()), - kind=Kinds.club_ae, + groupement_id=club_ae.id, name="Test prez", mandate_year=2023, deactivated=False, @@ -218,21 +242,22 @@ async def init_objects(): # ---------------------------------------------------------------------------- # # Get tests # # ---------------------------------------------------------------------------- # -def test_get_all_associations(client: TestClient): +def get_all_groupements(client: TestClient): response = client.get( - "/phonebook/associations/", + "/phonebook/groupements/", headers={"Authorization": f"Bearer {token_simple}"}, ) assert response.status_code == 200 assert len(response.json()) == 3 -def test_get_all_association_kinds_simple(client: TestClient): +def test_get_all_associations(client: TestClient): response = client.get( - "/phonebook/associations/kinds", + "/phonebook/associations/", headers={"Authorization": f"Bearer {token_simple}"}, ) - assert response.json()["kinds"] == [kind.value for kind in Kinds] + assert response.status_code == 200 + assert len(response.json()) == 3 def test_get_all_roletags_simple(client: TestClient): @@ -278,12 +303,50 @@ def test_get_member_by_id_simple(client: TestClient): # ---------------------------------------------------------------------------- # +def test_create_association_groupement_simple(client: TestClient): + response = client.post( + "/phonebook/groupements/", + json={ + "name": "Section AE", + }, + headers={"Authorization": f"Bearer {token_simple}"}, + ) + assert response.status_code == 403 + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + assert len(groupements) == 3 + + +def test_create_association_groupement_BDE(client: TestClient): + response = client.post( + "/phonebook/groupements/", + json={ + "name": "Section USE", + }, + headers={"Authorization": f"Bearer {token_BDE}"}, + ) + assert response.status_code == 201 + + groupement = response.json() + assert groupement["name"] == "Section USE" + assert isinstance(groupement["id"], str) + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + assert len(groupements) == 4 + + def test_create_association_simple(client: TestClient): response = client.post( "/phonebook/associations/", json={ "name": "Bazar", - "kind": "Section USE", + "groupement_id": str(section_ae.id), "mandate_year": 2023, "description": "Bazar description", }, @@ -303,7 +366,7 @@ def test_create_association_admin(client: TestClient): "/phonebook/associations/", json={ "name": "Bazar", - "kind": "Section USE", + "groupement_id": str(section_ae.id), "mandate_year": 2023, "description": "Bazar description", }, @@ -314,7 +377,7 @@ def test_create_association_admin(client: TestClient): assert response.status_code == 201 assert association["name"] == "Bazar" - assert association["kind"] == "Section USE" + assert association["groupement_id"] == str(section_ae.id) assert association["mandate_year"] == 2023 assert association["description"] == "Bazar description" assert isinstance(association["id"], str) @@ -325,7 +388,7 @@ def test_create_association_with_related_groups(client: TestClient): "/phonebook/associations/", json={ "name": "Bazar2", - "kind": "Section USE", + "groupement_id": str(section_ae.id), "mandate_year": 2023, "description": "Bazar description", "associated_groups": [association1_group.id], @@ -343,7 +406,7 @@ def test_create_association_with_related_groups(client: TestClient): assert response.status_code == 201 assert association["name"] == "Bazar2" - assert association["kind"] == "Section USE" + assert association["groupement_id"] == str(section_ae.id) assert association["mandate_year"] == 2023 assert association["description"] == "Bazar description" assert association["associated_groups"] == [association1_group.id] @@ -551,6 +614,58 @@ def test_add_association_group_admin(client: TestClient): # ---------------------------------------------------------------------------- # # Update tests # # ---------------------------------------------------------------------------- # +def test_update_association_groupement_simple(client: TestClient): + response = client.patch( + f"/phonebook/groupements/{section_ae.id}", + json={ + "name": "Section AE modifié", + }, + headers={"Authorization": f"Bearer {token_simple}"}, + ) + assert response.status_code == 403 + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + section = next( + ( + groupement + for groupement in groupements + if groupement["id"] == str(section_ae.id) + ), + None, + ) + assert section is not None + assert section["name"] == "Section AE" + + +def test_update_association_groupement_BDE(client: TestClient): + response = client.patch( + f"/phonebook/groupements/{section_ae.id}", + json={ + "name": "Section AE modifié", + }, + headers={"Authorization": f"Bearer {token_BDE}"}, + ) + assert response.status_code == 204 + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + section = next( + ( + groupement + for groupement in groupements + if groupement["id"] == str(section_ae.id) + ), + None, + ) + assert section is not None + assert section["name"] == "Section AE modifié" + + def test_update_association_simple(client: TestClient): response = client.patch( f"/phonebook/associations/{association1.id}/", @@ -920,6 +1035,66 @@ def test_update_membership_order(client: TestClient): # ---------------------------------------------------------------------------- # # Delete tests # # ---------------------------------------------------------------------------- # +def test_delete_groupement_simple(client: TestClient): + response = client.delete( + f"/phonebook/groupements/{section_ae.id}", + headers={"Authorization": f"Bearer {token_simple}"}, + ) + assert response.status_code == 403 + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + section = next( + ( + groupement + for groupement in groupements + if groupement["id"] == str(section_ae.id) + ), + None, + ) + assert section is not None + + +def test_delete_groupement_BDE(client: TestClient): + response = client.delete( + f"/phonebook/groupements/{section_ae.id}", + headers={"Authorization": f"Bearer {token_BDE}"}, + ) + assert response.status_code == 400 + + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + section = next( + ( + groupement + for groupement in groupements + if groupement["id"] == str(section_ae.id) + ), + None, + ) + assert section is not None + + +async def test_delete_empty_groupement_BDE(client: TestClient): + empty_groupement = models_phonebook.AssociationGroupement( + id=uuid.uuid4(), + name="Empty Groupement", + ) + await add_object_to_db(empty_groupement) + response = client.delete( + f"/phonebook/groupements/{empty_groupement.id}", + headers={"Authorization": f"Bearer {token_BDE}"}, + ) + assert response.status_code == 204 + groupements = client.get( + "/phonebook/groupements/", + headers={"Authorization": f"Bearer {token_simple}"}, + ).json() + assert not any(g["id"] == str(empty_groupement.id) for g in groupements) def test_delete_membership_simple(client: TestClient): From 41561aed8654afb1a5db27fd12855af2c9548cbd Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 00:02:17 +0200 Subject: [PATCH 2/6] typo --- app/modules/phonebook/endpoints_phonebook.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index 730785fdc2..f1a80b1a4a 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -66,22 +66,22 @@ async def get_all_role_tags( @module.router.get( - "/phonebook/groupements", + "/phonebook/groupements/", response_model=list[schemas_phonebook.AssociationGroupement], status_code=200, ) -async def get_all_kinds( +async def get_all_groupements( user: models_users.CoreUser = Depends(is_user_an_ecl_member), db: AsyncSession = Depends(get_db), ): """ - Return all available kinds of from Kinds enum. + Return all groupements from database as a list of AssociationGroupement schemas """ return await cruds_phonebook.get_all_groupements(db) @module.router.post( - "/phonebook/groupements", + "/phonebook/groupements/", response_model=schemas_phonebook.AssociationGroupement, status_code=201, ) From 19949d946e4c669a53fe8345de30ce8ce1bb15d1 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 00:02:17 +0200 Subject: [PATCH 3/6] fix: adapt factories to new system --- app/modules/phonebook/factory_phonebook.py | 99 ++++++++++++---------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/app/modules/phonebook/factory_phonebook.py b/app/modules/phonebook/factory_phonebook.py index 9ef0d90b5e..222ea2f734 100644 --- a/app/modules/phonebook/factory_phonebook.py +++ b/app/modules/phonebook/factory_phonebook.py @@ -1,79 +1,88 @@ import random import uuid +from faker import Faker from sqlalchemy.ext.asyncio import AsyncSession from app.core.users import cruds_users from app.core.users.factory_users import CoreUsersFactory from app.core.utils.config import Settings -from app.modules.phonebook import cruds_phonebook, models_phonebook -from app.modules.phonebook.types_phonebook import Kinds, RoleTags +from app.modules.phonebook import cruds_phonebook, schemas_phonebook +from app.modules.phonebook.types_phonebook import RoleTags from app.types.factory import Factory +faker = Faker("fr_FR") + class PhonebookFactory(Factory): depends_on = [CoreUsersFactory] @classmethod - async def create_association(cls, db: AsyncSession): - association_id_1 = str(uuid.uuid4()) - await cruds_phonebook.create_association( - association=models_phonebook.Association( - id=association_id_1, - name="Eclair", - description="L'asso d'informatique la plus cool !", - deactivated=False, - kind=Kinds.section_ae, - mandate_year=2025, + async def create_association_groupement(cls, db: AsyncSession) -> list[uuid.UUID]: + groupement_ids = [uuid.uuid4() for _ in range(3)] + await cruds_phonebook.create_groupement( + schemas_phonebook.AssociationGroupement( + id=groupement_ids[0], + name="Section AE", ), db=db, ) - - await cruds_phonebook.create_membership( - membership=models_phonebook.Membership( - id=str(uuid.uuid4()), - user_id=CoreUsersFactory.demo_users_id[0], - association_id=association_id_1, - mandate_year=2025, - role_name="Prez", - role_tags=RoleTags.president.name, - member_order=1, + await cruds_phonebook.create_groupement( + schemas_phonebook.AssociationGroupement( + id=groupement_ids[1], + name="Club AE", ), db=db, ) - - association_id_2 = str(uuid.uuid4()) - await cruds_phonebook.create_association( - association=models_phonebook.Association( - id=association_id_2, - name="Association 2", - description="Description de l'asso 2", - associated_groups=[], - deactivated=False, - kind=Kinds.section_use, - mandate_year=2025, + await cruds_phonebook.create_groupement( + schemas_phonebook.AssociationGroupement( + id=groupement_ids[2], + name="Section USE", ), db=db, ) - users = await cruds_users.get_users(db=db) - tags = list(RoleTags) - for i, user in enumerate(random.sample(users, 10)): - await cruds_phonebook.create_membership( - membership=models_phonebook.Membership( - id=str(uuid.uuid4()), - user_id=user.id, - association_id=association_id_2, + return groupement_ids + + @classmethod + async def create_association( + cls, + db: AsyncSession, + groupement_ids: list[uuid.UUID], + ): + for i in range(5): + association_id = str(uuid.uuid4()) + await cruds_phonebook.create_association( + association=schemas_phonebook.AssociationComplete( + id=association_id, + groupement_id=groupement_ids[i % len(groupement_ids)], + name=faker.company(), + description="Description de l'association", + associated_groups=[], + deactivated=False, mandate_year=2025, - role_name=f"VP {i}", - role_tags=tags[i].name if i < len(tags) else "", - member_order=i, ), db=db, ) + users = await cruds_users.get_users(db=db) + tags = list(RoleTags) + for j, user in enumerate(random.sample(users, 10)): + await cruds_phonebook.create_membership( + membership=schemas_phonebook.MembershipComplete( + id=str(uuid.uuid4()), + user_id=user.id, + association_id=association_id, + mandate_year=2025, + role_name=f"VP {j}", + role_tags=tags[j].name if j < len(tags) else "", + member_order=j, + ), + db=db, + ) @classmethod async def run(cls, db: AsyncSession, settings: Settings) -> None: - await cls.create_association(db) + groupement_ids = await cls.create_association_groupement(db=db) + await cls.create_association(db, groupement_ids=groupement_ids) @classmethod async def should_run(cls, db: AsyncSession): From a703dde458d50baf4485f2564450f689b5e59cab Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 00:02:17 +0200 Subject: [PATCH 4/6] fix: rebase --- .../{35_phonebook-groupement.py => 36_phonebook-groupement.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{35_phonebook-groupement.py => 36_phonebook-groupement.py} (99%) diff --git a/migrations/versions/35_phonebook-groupement.py b/migrations/versions/36_phonebook-groupement.py similarity index 99% rename from migrations/versions/35_phonebook-groupement.py rename to migrations/versions/36_phonebook-groupement.py index f936833612..a39f45692e 100644 --- a/migrations/versions/35_phonebook-groupement.py +++ b/migrations/versions/36_phonebook-groupement.py @@ -17,7 +17,7 @@ # revision identifiers, used by Alembic. revision: str = "e81453aa7341" -down_revision: str | None = "d14266761430" +down_revision: str | None = "7da0e98a9e32" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 53e3fcb0a3f4c5aa5d27960c5d9839c095aad769 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 00:02:17 +0200 Subject: [PATCH 5/6] feat: change default logo --- assets/images/default_association_picture.png | Bin 1053 -> 25964 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/images/default_association_picture.png b/assets/images/default_association_picture.png index c7aa71484d20831f5478836145f8a4a801fc10ad..9c70f81a95be84f4f9235769a803125b35606678 100644 GIT binary patch literal 25964 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 delta 1031 zcmV+i1o->x$^o4SHi=M9M-2)Z3IG5A4M|8uQUCw|C;$KeCVIEMkjv*s!2paR9J*gwhN6y6$&CHinQ3qL{TegF;tTh(vVo& z8WWPw#mt1;$;{+F&VirHojL!FiE{Wpj9|1%nIhj1!14CVNHH2z$Wkj_$|1cLR|$v1^49Ur{wyC zkpi)F3=7=_a@)aQtHgZ{Zps&o>X1v~NJ?%S zI9Dg`4A`0$+}D6y61#sZ1ixtz_eNUqYyiPyvD}`sd}~0qzF|0`Ud_%0N#bAd7751Ns8tZih(FKtQJGZ3dbH;W{A_ych_`W+o

%` z*q;{sqyf1ko{Qz~7MB{teOMt#r?MD8@I-*oOJYg9xg1aCM+sAC?qq&b7cdxr)dW12 z2yP7kjG&`Cb`X7k3DLGEg580DaJeJY!vgdsf`11?UTK>R#)5@J@Io-;jn~E?*gM*_@Z#esMPodmuM@We;r-=P4kSzx$wq2ATt<4!B>@d$Q_vki{k z>lF+2s^mL$(w3FM>n`H{2INMKKf$F1o0XwD z$-+)}$m;jyGvaNPR|>jwm-3!6nQ?nWIizbp{pLqV-Yb44F68tW7hf_y3s*+|nPjz< zN<^i9FR%bif^&7v%Mu;HF5nKJmFQgZBl1gL%KwfT#cAmVOqu`y002ovPDHLkV1k#A B&A9*o From ad6f1576fe57d38a5b5a14ae56f76f899fa6732d Mon Sep 17 00:00:00 2001 From: Thonyk Date: Sun, 10 Aug 2025 01:13:12 +0200 Subject: [PATCH 6/6] fix: rebase --- migrations/versions/36_phonebook-groupement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/36_phonebook-groupement.py b/migrations/versions/36_phonebook-groupement.py index a39f45692e..c4f426a81c 100644 --- a/migrations/versions/36_phonebook-groupement.py +++ b/migrations/versions/36_phonebook-groupement.py @@ -17,7 +17,7 @@ # revision identifiers, used by Alembic. revision: str = "e81453aa7341" -down_revision: str | None = "7da0e98a9e32" +down_revision: str | None = "52ce7195775f" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None