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..f1a80b1a4a 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( +async def get_all_groupements( + user: models_users.CoreUser = Depends(is_user_an_ecl_member), + db: AsyncSession = Depends(get_db), +): + """ + Return all groupements from database as a list of AssociationGroupement schemas + """ + 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), ): """ - Return all available kinds of from Kinds enum. + Delete a groupement + + **This endpoint is only usable by CAA and BDE** """ - kinds = await cruds_phonebook.get_all_kinds() - return schemas_phonebook.KindsReturn(kinds=kinds) + 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/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): 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/assets/images/default_association_picture.png b/assets/images/default_association_picture.png index c7aa71484d..9c70f81a95 100644 Binary files a/assets/images/default_association_picture.png and b/assets/images/default_association_picture.png differ diff --git a/migrations/versions/36_phonebook-groupement.py b/migrations/versions/36_phonebook-groupement.py new file mode 100644 index 0000000000..a39f45692e --- /dev/null +++ b/migrations/versions/36_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 = "7da0e98a9e32" +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):