From 13a1b4dd1e64e54e4d2328c5a8d426148951ed7f Mon Sep 17 00:00:00 2001 From: Sebastiaan Date: Tue, 10 Jun 2025 20:23:50 +0200 Subject: [PATCH] Implement associations --- backend/app/api/main.py | 2 + backend/app/api/routes/associations.py | 132 +++++++++++++ backend/app/core/db.py | 3 + backend/app/models/association.py | 74 +++++++ backend/app/models/base.py | 1 + backend/app/models/mixin.py | 2 +- backend/app/models/user.py | 1 + .../app/tests/api/routes/test_association.py | 184 ++++++++++++++++++ backend/app/tests/utils/association.py | 12 ++ 9 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/routes/associations.py create mode 100644 backend/app/models/association.py create mode 100644 backend/app/tests/api/routes/test_association.py create mode 100644 backend/app/tests/utils/association.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 824a50a..f441fb3 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api.routes import ( events, teams, + associations, login, private, users, @@ -18,6 +19,7 @@ api_router.include_router(utils.router) api_router.include_router(events.router) api_router.include_router(teams.router) +api_router.include_router(associations.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/associations.py b/backend/app/api/routes/associations.py new file mode 100644 index 0000000..b510fa4 --- /dev/null +++ b/backend/app/api/routes/associations.py @@ -0,0 +1,132 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, status +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models.base import ( + ApiTags, + Message, + RowId, +) +from app.models.association import ( + Association, + AssociationCreate, + AssociationUpdate, + AssociationPublic, + AssociationsPublic, +) +from app.models.user import ( + PermissionModule, + PermissionPart, + PermissionRight, +) + +router = APIRouter(prefix="/associations", tags=[ApiTags.ASSOCIATIONS]) + + +# region # Associations ######################################################## + +@router.get("/", response_model=AssociationsPublic) +def read_associations( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve all associations. + """ + + if current_user.has_permissions( + module=PermissionModule.ASSOCIATION, + part=PermissionPart.ADMIN, + rights=PermissionRight.READ, + ): + count_statement = select(func.count()).select_from(Association) + count = session.exec(count_statement).one() + statement = select(Association).offset(skip).limit(limit) + associations = session.exec(statement).all() + return AssociationsPublic(data=associations, count=count) + + return AssociationsPublic(data=[], count=0) + + +@router.get("/{id}", response_model=AssociationPublic) +def read_association(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any: + """ + Get association by ID. + """ + association = session.get(Association, id) + if not association: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Association not found") + + if not current_user.has_permissions( + module=PermissionModule.ASSOCIATION, + part=PermissionPart.ADMIN, + rights=PermissionRight.READ, + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + return association + + +@router.post("/", response_model=AssociationPublic) +def create_association( + *, session: SessionDep, current_user: CurrentUser, association_in: AssociationCreate +) -> Any: + """ + Create new association. + """ + + if not current_user.has_permissions( + module=PermissionModule.ASSOCIATION, + part=PermissionPart.ADMIN, + rights=PermissionRight.CREATE, + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + association = Association.create(create_obj=association_in, session=session) + return association + + +@router.put("/{id}", response_model=AssociationPublic) +def update_association( + *, session: SessionDep, current_user: CurrentUser, id: RowId, association_in: AssociationUpdate +) -> Any: + """ + Update a association. + """ + association = session.get(Association, id) + if not association: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Association not found") + + if not current_user.has_permissions( + module=PermissionModule.ASSOCIATION, + part=PermissionPart.ADMIN, + rights=PermissionRight.UPDATE, + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + association = Association.update(db_obj=association, in_obj=association_in, session=session) + return association + + +@router.delete("/{id}") +def delete_association(session: SessionDep,current_user: CurrentUser, id: RowId) -> Message: + """ + Delete a association. + """ + association = session.get(Association, id) + if not association: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Association not found") + + if not current_user.has_permissions( + module=PermissionModule.ASSOCIATION, + part=PermissionPart.ADMIN, + rights=PermissionRight.DELETE, + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + session.delete(association) + session.commit() + return Message(message="Association deleted successfully") + +# endregion diff --git a/backend/app/core/db.py b/backend/app/core/db.py index df4c7d5..331a750 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -5,6 +5,9 @@ from app.models.event import ( Event, EventCreate, ) +from app.models.association import ( + Association, +) from app.models.team import ( Team, TeamCreate, diff --git a/backend/app/models/association.py b/backend/app/models/association.py new file mode 100644 index 0000000..45e744b --- /dev/null +++ b/backend/app/models/association.py @@ -0,0 +1,74 @@ +from typing import TYPE_CHECKING + +from sqlmodel import ( + Session, +) + +from . import mixin +from .base import ( + BaseSQLModel, +) + +# region # Association ######################################################### + + +class AssociationBase( + mixin.Name, + mixin.Contact, + mixin.ScoutingId, + BaseSQLModel, +): + pass + + +# Properties to receive via API on creation +class AssociationCreate(AssociationBase): + pass + + +# Properties to receive via API on update, all are optional +class AssociationUpdate(AssociationBase): + pass + + +class Association(mixin.RowId, AssociationBase, table=True): + # --- database only items -------------------------------------------------- + + # --- read only items ------------------------------------------------------ + + # --- back_populates links ------------------------------------------------- + + # --- CRUD actions --------------------------------------------------------- + @classmethod + def create(cls, *, session: Session, create_obj: AssociationCreate) -> "Association": + data_obj = create_obj.model_dump(exclude_unset=True) + + db_obj = cls.model_validate(data_obj) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + @classmethod + def update( + cls, *, session: Session, db_obj: "Association", in_obj: AssociationUpdate + ) -> "Association": + data_obj = in_obj.model_dump(exclude_unset=True) + db_obj.sqlmodel_update(data_obj) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +# Properties to return via API, id is always required +class AssociationPublic(mixin.RowIdPublic, AssociationBase): + pass + + +class AssociationsPublic(BaseSQLModel): + data: list[AssociationPublic] + count: int + + +# endregion diff --git a/backend/app/models/base.py b/backend/app/models/base.py index 9b2b021..6060ad3 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -56,6 +56,7 @@ class ApiTags(DocumentedStrEnum): EVENTS = "Events" TEAMS = "Teams" + ASSOCIATIONS = "Associations" # endregion diff --git a/backend/app/models/mixin.py b/backend/app/models/mixin.py index eafaacc..93029de 100644 --- a/backend/app/models/mixin.py +++ b/backend/app/models/mixin.py @@ -54,7 +54,7 @@ class EmailUpdate(Email): class ScoutingId(BaseModel): - scouting_id: str | None = Field(default=None, max_length=32) + scouting_id: str | None = Field(default=None, max_length=32, description="Association registration number") class Password(BaseModel): diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 988afeb..2311326 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -27,6 +27,7 @@ class PermissionModule(DocumentedStrEnum): USER = auto_enum() EVENT = auto_enum() TEAM = auto_enum() + ASSOCIATION = auto_enum() class PermissionPart(DocumentedStrEnum): diff --git a/backend/app/tests/api/routes/test_association.py b/backend/app/tests/api/routes/test_association.py new file mode 100644 index 0000000..6dfb360 --- /dev/null +++ b/backend/app/tests/api/routes/test_association.py @@ -0,0 +1,184 @@ +import uuid + +from fastapi import status +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.association import create_random_association + + +def test_create_association(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + data = { + "name": "Scouting Maurits-Viool", + "contact": "Sebas", + "scouting_id": "2577", + } + response = client.post( + f"{settings.API_V1_STR}/associations/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == data["name"] + assert content["contact"] == data["contact"] + assert content["scouting_id"] == data["scouting_id"] + assert "id" in content + + +def test_create_association_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + data = { + "name": "Scouting Maurits-Viool", + "contact": "Sebas", + "scouting_id": "2577", + } + response = client.post( + f"{settings.API_V1_STR}/associations/", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_read_association(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + response = client.get( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["id"] == str(association.id) + assert content["name"] == association.name + assert content["contact"] == association.contact + assert content["scouting_id"] == association.scouting_id + + +def test_read_association_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + response = client.get( + f"{settings.API_V1_STR}/associations/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Association not found" + + +def test_read_association_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + response = client.get( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_read_associations(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + create_random_association(db) + create_random_association(db) + response = client.get( + f"{settings.API_V1_STR}/associations/", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert "count" in content + assert content["count"] >= 2 + assert "data" in content + assert isinstance(content["data"], list) + assert len(content["data"]) <= content["count"] + + +def test_read_associations_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + create_random_association(db) + create_random_association(db) + response = client.get( + f"{settings.API_V1_STR}/associations/", + headers=normal_user_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert "count" in content + assert content["count"] == 0 + assert "data" in content + assert isinstance(content["data"], list) + assert len(content["data"]) == 0 + + +def test_update_association(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + data = { + "name": "Updated name", + "contact": "Updated contact", + } + response = client.put( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["id"] == str(association.id) + assert content["name"] == data["name"] + assert content["contact"] == data["contact"] + assert content["scouting_id"] == association.scouting_id + + +def test_update_association_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None: + data = { + "name": "Not found", + "contact": "Not found", + } + response = client.put( + f"{settings.API_V1_STR}/associations/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Association not found" + + +def test_update_association_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + data = { + "name": "No permissions", + "contact": "No permissions", + } + response = client.put( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_delete_association(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + response = client.delete( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Association deleted successfully" + + +def test_delete_association_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None: + response = client.delete( + f"{settings.API_V1_STR}/associations/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Association not found" + + +def test_delete_association_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + association = create_random_association(db) + response = client.delete( + f"{settings.API_V1_STR}/associations/{association.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" diff --git a/backend/app/tests/utils/association.py b/backend/app/tests/utils/association.py new file mode 100644 index 0000000..224286d --- /dev/null +++ b/backend/app/tests/utils/association.py @@ -0,0 +1,12 @@ +from sqlmodel import Session + +from app.models.association import Association, AssociationCreate +from app.tests.utils.utils import random_lower_string + + +def create_random_association(db: Session, name: str = None) -> Association: + if not name: + name = random_lower_string() + + association_in = AssociationCreate(name=name) + return Association.create(session=db, create_obj=association_in)