diff --git a/app/core/associations/__init__.py b/app/core/associations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/core/associations/cruds_associations.py b/app/core/associations/cruds_associations.py new file mode 100644 index 0000000000..0c45d4ae0f --- /dev/null +++ b/app/core/associations/cruds_associations.py @@ -0,0 +1,70 @@ +import uuid +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.associations import models_associations, schemas_associations + + +async def get_associations( + db: AsyncSession, +) -> Sequence[models_associations.CoreAssociation]: + result = await db.execute(select(models_associations.CoreAssociation)) + return result.scalars().all() + + +async def get_associations_for_groups( + group_ids: list[str], + db: AsyncSession, +) -> Sequence[models_associations.CoreAssociation]: + result = await db.execute( + select(models_associations.CoreAssociation).where( + models_associations.CoreAssociation.group_id.in_(group_ids), + ), + ) + return result.scalars().all() + + +async def get_association_by_id( + association_id: uuid.UUID, + db: AsyncSession, +) -> models_associations.CoreAssociation | None: + result = await db.execute( + select(models_associations.CoreAssociation).where( + models_associations.CoreAssociation.id == association_id, + ), + ) + return result.scalars().first() + + +async def get_association_by_name( + name: str, + db: AsyncSession, +) -> models_associations.CoreAssociation | None: + result = await db.execute( + select(models_associations.CoreAssociation).where( + models_associations.CoreAssociation.name == name, + ), + ) + return result.scalars().first() + + +async def create_association( + db: AsyncSession, + association: models_associations.CoreAssociation, +) -> None: + db.add(association) + + +async def update_association( + db: AsyncSession, + association_id: UUID, + association_update: schemas_associations.AssociationUpdate, +) -> None: + await db.execute( + update(models_associations.CoreAssociation) + .where(models_associations.CoreAssociation.id == association_id) + .values(**association_update.model_dump(exclude_unset=True)), + ) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py new file mode 100644 index 0000000000..c88f9d7e32 --- /dev/null +++ b/app/core/associations/endpoints_associations.py @@ -0,0 +1,216 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.associations import ( + cruds_associations, + models_associations, + schemas_associations, +) +from app.core.associations.factory_associations import AssociationsFactory +from app.core.groups.groups_type import GroupType +from app.core.users import models_users +from app.dependencies import ( + get_db, + is_user, + is_user_in, +) +from app.types.content_type import ContentType +from app.types.module import CoreModule +from app.utils.tools import get_file_from_data, save_file_as_data + +router = APIRouter(tags=["Associations"]) + +core_module = CoreModule( + root="associations", + tag="Associations", + router=router, + factory=AssociationsFactory(), +) + + +@router.get( + "/associations/", + response_model=list[schemas_associations.Association], + status_code=200, +) +async def read_associations( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Return all associations + + **User must be authenticated** + """ + + return await cruds_associations.get_associations(db=db) + + +@router.get( + "/associations/me", + response_model=list[schemas_associations.Association], + status_code=200, +) +async def read_associations_me( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Return all associations the current user has the right to manage + + **User must be authenticated** + """ + + return await cruds_associations.get_associations_for_groups( + group_ids=user.group_ids, + db=db, + ) + + +@router.post( + "/associations/", + response_model=schemas_associations.Association, + status_code=201, +) +async def create_association( + association: schemas_associations.AssociationBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.admin)), +): + """ + Create a new association + + **This endpoint is only usable by administrators** + """ + if ( + await cruds_associations.get_association_by_name(name=association.name, db=db) + is not None + ): + raise HTTPException( + status_code=400, + detail="A association with this name already exist", + ) + + db_association = models_associations.CoreAssociation( + id=uuid.uuid4(), + name=association.name, + group_id=association.group_id, + ) + await cruds_associations.create_association( + association=db_association, + db=db, + ) + + return db_association + + +@router.patch( + "/associations/{association_id}", + status_code=204, +) +async def update_association( + association_id: uuid.UUID, + association_update: schemas_associations.AssociationUpdate, + db: AsyncSession = Depends(get_db), + user=Depends(is_user_in(GroupType.admin)), +): + """ + Update the name or the description of a association. + + **This endpoint is only usable by administrators** + """ + association = await cruds_associations.get_association_by_id( + association_id=association_id, + db=db, + ) + if not association: + raise HTTPException(status_code=404, detail="Association not found") + + # If the request ask to update the association name, we need to check it is available + if association_update.name and association_update.name != association.name: + if ( + await cruds_associations.get_association_by_name( + name=association_update.name, + db=db, + ) + is not None + ): + raise HTTPException( + status_code=400, + detail="A association with the name already exist", + ) + + await cruds_associations.update_association( + db=db, + association_id=association_id, + association_update=association_update, + ) + + +@router.post( + "/associations/{association_id}/logo", + status_code=204, +) +async def create_association_logo( + association_id: uuid.UUID, + image: UploadFile, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.admin)), +): + """ + Upload a logo for an association + + **This endpoint is only usable by administrators** + """ + + association = await cruds_associations.get_association_by_id( + db=db, + association_id=association_id, + ) + if not association: + raise HTTPException(status_code=404, detail="Association not found") + + await save_file_as_data( + upload_file=image, + directory="associations/logos", + filename=association_id, + max_file_size=4 * 1024 * 1024, + accepted_content_types=[ + ContentType.jpg, + ContentType.png, + ContentType.webp, + ], + ) + + +@router.get( + "/associations/{association_id}/logo", + response_class=FileResponse, + status_code=200, +) +async def read_association_logo( + association_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Get the logo of an association + + **User must be authenticated** + """ + + association = await cruds_associations.get_association_by_id( + db=db, + association_id=association_id, + ) + if not association: + raise HTTPException(status_code=404, detail="Association not found") + + return get_file_from_data( + directory="associations/logos", + filename=association_id, + raise_http_exception=True, + ) diff --git a/app/core/associations/factory_associations.py b/app/core/associations/factory_associations.py new file mode 100644 index 0000000000..49c3ec2ea0 --- /dev/null +++ b/app/core/associations/factory_associations.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.associations import cruds_associations +from app.core.associations.models_associations import CoreAssociation +from app.core.groups.factory_groups import CoreGroupsFactory +from app.core.utils.config import Settings +from app.types.factory import Factory + + +class AssociationsFactory(Factory): + association_ids = [ + uuid.uuid4(), + uuid.uuid4(), + ] + + depends_on = [CoreGroupsFactory] + + @classmethod + async def create_associations(cls, db: AsyncSession): + descriptions = ["Association 1", "Association 2"] + for i in range(len(CoreGroupsFactory.groups_ids)): + await cruds_associations.create_association( + db=db, + association=CoreAssociation( + id=cls.association_ids[i], + name=descriptions[i], + group_id=CoreGroupsFactory.groups_ids[i], + ), + ) + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + await cls.create_associations(db=db) + + @classmethod + async def should_run(cls, db: AsyncSession): + return len(await cruds_associations.get_associations(db=db)) > 0 diff --git a/app/core/associations/models_associations.py b/app/core/associations/models_associations.py new file mode 100644 index 0000000000..b14976746f --- /dev/null +++ b/app/core/associations/models_associations.py @@ -0,0 +1,12 @@ +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from app.types.sqlalchemy import Base, PrimaryKey + + +class CoreAssociation(Base): + __tablename__ = "associations_associations" + + id: Mapped[PrimaryKey] + name: Mapped[str] = mapped_column(unique=True) + group_id: Mapped[str] = mapped_column(ForeignKey("core_group.id")) diff --git a/app/core/associations/schemas_associations.py b/app/core/associations/schemas_associations.py new file mode 100644 index 0000000000..6194f0a689 --- /dev/null +++ b/app/core/associations/schemas_associations.py @@ -0,0 +1,17 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class AssociationBase(BaseModel): + name: str + group_id: str + + +class Association(AssociationBase): + id: UUID + + +class AssociationUpdate(BaseModel): + name: str | None = None + group_id: str | None = None diff --git a/app/core/users/models_users.py b/app/core/users/models_users.py index f4936e93c3..0da52a2db8 100644 --- a/app/core/users/models_users.py +++ b/app/core/users/models_users.py @@ -63,6 +63,10 @@ def full_name(self) -> str: return f"{self.firstname} {self.name} ({self.nickname})" return f"{self.firstname} {self.name}" + @property + def group_ids(self) -> list[str]: + return [group.id for group in self.groups] + class CoreUserUnconfirmed(Base): __tablename__ = "core_user_unconfirmed" diff --git a/app/dependencies.py b/app/dependencies.py index 4fcf7b188f..0f0c849772 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -11,6 +11,7 @@ async def get_users(db: AsyncSession = Depends(get_db)): from collections.abc import AsyncGenerator, Callable, Coroutine from functools import lru_cache from typing import Annotated, Any, cast +from uuid import UUID import calypsso import redis @@ -21,6 +22,7 @@ async def get_users(db: AsyncSession = Depends(get_db)): AsyncSession, ) +from app.core.associations import cruds_associations from app.core.auth import schemas_auth from app.core.checkout.payment_tool import PaymentTool from app.core.checkout.types_checkout import HelloAssoConfigName @@ -54,6 +56,7 @@ async def get_users(db: AsyncSession = Depends(get_db)): ) from app.utils.tools import ( is_user_external, + is_user_member_of_an_association, is_user_member_of_any_group, ) @@ -470,7 +473,6 @@ async def is_user_in( user: models_users.CoreUser = Depends( is_user(included_groups=[group_id], exclude_external=exclude_external), ), - request_id: str = Depends(get_request_id), ) -> models_users.CoreUser: """ A dependency that checks that user is a member of the group with the given id then returns the corresponding user. @@ -479,3 +481,30 @@ async def is_user_in( return user return is_user_in + + +async def is_user_in_association( + association_id: UUID, + user: models_users.CoreUser = Depends(is_user()), + db: AsyncSession = Depends(get_db), +) -> models_users.CoreUser: + """ + Check if a user is a member of a specific association. + + The endpoint path must contains `association_id` + """ + + association = await cruds_associations.get_association_by_id( + association_id=association_id, + db=db, + ) + if association is None or not is_user_member_of_an_association( + user=user, + association=association, + ): + raise HTTPException( + status_code=403, + detail="User is not a member of the association", + ) + + return user diff --git a/app/utils/tools.py b/app/utils/tools.py index 071aaa82c8..4eefd224a5 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -23,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from weasyprint import CSS, HTML +from app.core.associations import models_associations from app.core.core_endpoints import cruds_core, models_core from app.core.groups import cruds_groups from app.core.groups.groups_type import AccountType, GroupType @@ -112,6 +113,20 @@ def is_user_member_of_any_group( return any(group_id in user_groups_id for group_id in allowed_groups) +def is_user_member_of_an_association( + user: models_users.CoreUser, + association: models_associations.CoreAssociation, +) -> bool: + """ + Check if the user is a member of the association + """ + + return is_user_member_of_any_group( + user=user, + allowed_groups=[association.group_id], + ) + + async def is_group_id_valid(group_id: str, db: AsyncSession) -> bool: """ Test if the provided group_id is a valid group. @@ -133,7 +148,7 @@ async def is_user_id_valid(user_id: str, db: AsyncSession) -> bool: async def save_file_as_data( upload_file: UploadFile, directory: str, - filename: str, + filename: str | UUID, max_file_size: int = 1024 * 1024 * 2, # 2 MB accepted_content_types: list[ContentType] | None = None, ): @@ -156,6 +171,9 @@ async def save_file_as_data( WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. """ + if isinstance(filename, UUID): + filename = str(filename) + if accepted_content_types is None: # Accept only images by default accepted_content_types = [ @@ -313,7 +331,6 @@ def get_file_from_data( WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. """ - path = get_file_path_from_data( directory, filename, diff --git a/migrations/versions/40-core_associations.py b/migrations/versions/40-core_associations.py new file mode 100644 index 0000000000..645597be2c --- /dev/null +++ b/migrations/versions/40-core_associations.py @@ -0,0 +1,52 @@ +"""empty message + +Create Date: 2025-08-15 11:36:39.286803 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2ca210263c74" +down_revision: str | None = "52ce71775f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "associations_associations", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("group_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["group_id"], + ["core_group.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + +def downgrade() -> None: + op.drop_table("associations_associations") + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/test_associations.py b/tests/test_associations.py new file mode 100644 index 0000000000..7d4469c22f --- /dev/null +++ b/tests/test_associations.py @@ -0,0 +1,113 @@ +import uuid +from pathlib import Path + +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.associations import models_associations +from app.core.groups.groups_type import GroupType +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +user_token: str +admin_user_token: str +eclair_user_token: str + +id_association = uuid.UUID("8aab79e7-1e15-456d-b6e2-11e4e9f77e4f") + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects() -> None: + global user_token, admin_user_token, eclair_user_token + + eclair_association = models_associations.CoreAssociation( + id=id_association, + name="test_association_eclair", + group_id=GroupType.eclair, + ) + await add_object_to_db(eclair_association) + + admin_user = await create_user_with_groups([GroupType.admin]) + admin_user_token = create_api_access_token(admin_user) + + eclair_user = await create_user_with_groups([GroupType.eclair]) + eclair_user_token = create_api_access_token(eclair_user) + + user = await create_user_with_groups([]) + user_token = create_api_access_token(user) + + +def test_get_associations(client: TestClient) -> None: + response = client.get( + "/associations/", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == str(id_association) + assert data[0]["name"] == "test_association_eclair" + + +def test_get_associations_me_without_associations(client: TestClient) -> None: + response = client.get( + "/associations/me", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 0 + + +def test_get_associations_me_with_associations(client: TestClient) -> None: + response = client.get( + "/associations/me", + headers={"Authorization": f"Bearer {eclair_user_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == str(id_association) + assert data[0]["name"] == "test_association_eclair" + + +def test_create_association(client: TestClient) -> None: + response = client.post( + "/associations/", + json={ + "name": "TestAsso", + "group_id": GroupType.eclair.value, + }, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + + +def test_update_association(client: TestClient) -> None: + response = client.patch( + f"/associations/{id_association}", + json={ + "name": "Group ECLAIR", + }, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +def test_create_and_read_logo(client: TestClient) -> None: + with Path("assets/images/default_profile_picture.png").open("rb") as image: + response = client.post( + f"/associations/{id_association}/logo", + files={"image": ("logo.png", image, "image/png")}, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/associations/{id_association}/logo", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200