diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 6eb0cb0..295daf4 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -25,6 +25,12 @@ from app.models.user import ( PermissionPart, PermissionRight, ) +from app.models.division import ( + DivisionTeamLink, + DivisionTeamLinkCreate, + DivisionTeamLinkUpdate, + DivisionTeamLinkPublic, +) router = APIRouter(prefix="/teams", tags=[ApiTags.TEAMS]) @@ -191,3 +197,113 @@ def delete_team(session: SessionDep,current_user: CurrentUser, id: RowId) -> Mes return Message(message="Team deleted successfully") # endregion + + +# region # Teams / Division #################################################### + +@router.get("/{id}/division", response_model=DivisionTeamLinkPublic, tags=[ApiTags.DIVISIONS]) +def read_team_divisions(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any: + """ + Get division from team by ID. + """ + team = session.get(Team, id) + if not team: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") + + event = session.get(Event, team.event_id) + if not event: # pragma: no cover + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + if not current_user.has_permissions( + module=PermissionModule.TEAM, + part=PermissionPart.ADMIN, + rights=PermissionRight.MANAGE_DIVISIONS, + ) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_DIVISIONS)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + return team.division_link + + +@router.post("/{id}/division", response_model=DivisionTeamLinkPublic, tags=[ApiTags.DIVISIONS]) +def create_team_division_link( + *, session: SessionDep, current_user: CurrentUser, team_in: DivisionTeamLinkCreate, id: RowId +) -> Any: + """ + Create new division link in team. + """ + + team = session.get(Team, id) + if not team: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") + + if team.division_link: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Team already linked to division") + + event = session.get(Event, team.event_id) + if not event: # pragma: no cover + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + if not current_user.has_permissions( + module=PermissionModule.TEAM, + part=PermissionPart.ADMIN, + rights=PermissionRight.MANAGE_DIVISIONS, + ) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_DIVISIONS)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + division_team_link = DivisionTeamLink.create(create_obj=team_in, session=session, team=team) + return division_team_link + + +@router.put("/{id}/division", response_model=DivisionTeamLinkPublic, tags=[ApiTags.DIVISIONS]) +def update_team_division_link( + *, session: SessionDep, current_user: CurrentUser, id: RowId, team_in: DivisionTeamLinkUpdate +) -> Any: + """ + Update division info inside team. + """ + team = session.get(Team, id) + if not team: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") + + # Check user's permissions for the existing event + event = session.get(Event, team.event_id) + if not event: # pragma: no cover + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + if not current_user.has_permissions( + module=PermissionModule.TEAM, + part=PermissionPart.ADMIN, + rights=PermissionRight.MANAGE_DIVISIONS, + ) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_DIVISIONS)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + # Update the team + division_team_link = DivisionTeamLink.update(db_obj=team.division_link, in_obj=team_in, session=session) + return division_team_link + + +@router.delete("/{id}/division", tags=[ApiTags.DIVISIONS]) +def delete_team_division_link(session: SessionDep, current_user: CurrentUser, id: RowId) -> Message: + """ + Delete a division link from a team. + """ + team = session.get(Team, id) + if not team: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found") + + event = session.get(Event, team.event_id) + if not event: # pragma: no cover + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + + if not current_user.has_permissions( + module=PermissionModule.TEAM, + part=PermissionPart.ADMIN, + rights=PermissionRight.MANAGE_DIVISIONS, + ) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_DIVISIONS)): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") + + session.delete(team.division_link) + session.commit() + return Message(message="Division deleted from team successfully") + +# endregion diff --git a/backend/app/models/division.py b/backend/app/models/division.py index ec9ab78..ee32074 100644 --- a/backend/app/models/division.py +++ b/backend/app/models/division.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from sqlalchemy.orm.sync import update from sqlmodel import ( Session, Field, @@ -14,6 +15,82 @@ from .base import ( if TYPE_CHECKING: from .association import Association + from .team import Team + + +# region # Divisions / Team Link ###################################### + +class DivisionTeamLinkBase( + mixin.Name, + BaseSQLModel, +): + pass + + +class DivisionTeamLinkCreate(DivisionTeamLinkBase): + division_id: RowId | None = Field(default=None) + + +class DivisionTeamLinkUpdate(DivisionTeamLinkBase): + pass + + +class DivisionTeamLink(DivisionTeamLinkBase, table=True): + # --- database only items -------------------------------------------------- + + # --- read only items ------------------------------------------------------ + team_id: RowId = Field( + default=None, + foreign_key="team.id", + nullable=False, + ondelete="CASCADE", + primary_key=True, + ) + + # --- back_populates links ------------------------------------------------- + division_id: RowId = Field( + default=None, + foreign_key="division.id", + nullable=False, + ondelete="CASCADE", + ) + division: "Division" = Relationship(back_populates="team_links") + team: "Team" = Relationship(back_populates="division_link") + + # Members (1 lid > meerdere teams | many-to-one) + + # --- CRUD actions --------------------------------------------------------- + @classmethod + def create(cls, *, session: Session, create_obj: DivisionTeamLinkCreate, team: "Team") -> "DivisionTeamLink": + data_obj = create_obj.model_dump(exclude_unset=True) + + db_obj = cls.model_validate(data_obj, update={"team_id": team.id}) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + @classmethod + def update( + cls, *, session: Session, db_obj: "DivisionTeamLink", in_obj: DivisionTeamLinkUpdate + ) -> "DivisionTeamLink": + 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 DivisionTeamLinkPublic(DivisionTeamLinkBase): + team_id: RowId + division_id: RowId + + + +# endregion + # region # Divisions ########################################################### @@ -45,7 +122,8 @@ class Division(mixin.RowId, DivisionBase, table=True): # --- read only items ------------------------------------------------------ # --- back_populates links ------------------------------------------------- - association: "Association" = Relationship(back_populates="divisions") # , cascade_delete=True) + association: "Association" = Relationship(back_populates="divisions") + team_links: list["DivisionTeamLink"] = Relationship(back_populates="division", cascade_delete=True) # --- CRUD actions --------------------------------------------------------- @classmethod diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 7948675..76d5b4f 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -102,7 +102,7 @@ class Event(mixin.RowId, EventBase, table=True): # --- many-to-many links --------------------------------------------------- user_links: list["EventUserLink"] = Relationship(back_populates="event", cascade_delete=True) - team_links: list["Team"] = Relationship(back_populates="event", cascade_delete=True) + teams: list["Team"] = Relationship(back_populates="event", cascade_delete=True) # --- CRUD actions --------------------------------------------------------- @classmethod diff --git a/backend/app/models/team.py b/backend/app/models/team.py index ac56896..8cb56b6 100644 --- a/backend/app/models/team.py +++ b/backend/app/models/team.py @@ -14,6 +14,7 @@ from .base import ( if TYPE_CHECKING: from .event import Event + from .division import DivisionTeamLink # region # Team ################################################################ @@ -28,9 +29,6 @@ class TeamBase( event_id: RowId = Field( foreign_key="event.id", nullable=False, ondelete="CASCADE" ) - # scouting_team_id: RowId | None = Field( - # foreign_key="ScoutingTeam.id", nullable=False, ondelete="CASCADE" - # ) # Properties to receive via API on creation @@ -49,8 +47,8 @@ class Team(mixin.RowId, TeamBase, table=True): # --- read only items ------------------------------------------------------ # --- back_populates links ------------------------------------------------- - event: "Event" = Relationship(back_populates="team_links")#, cascade_delete=True) - # team: "ScoutingTeam" = Relationship(back_populates="event_links", cascade_delete=True) + event: "Event" = Relationship(back_populates="teams") + division_link: "DivisionTeamLink" = Relationship(back_populates="team", cascade_delete=True) # --- CRUD actions --------------------------------------------------------- @classmethod @@ -85,4 +83,4 @@ class TeamsPublic(BaseSQLModel): count: int -# endregion \ No newline at end of file +# endregion diff --git a/backend/app/tests/api/routes/test_division.py b/backend/app/tests/api/routes/test_division.py index 17e211f..d761414 100644 --- a/backend/app/tests/api/routes/test_division.py +++ b/backend/app/tests/api/routes/test_division.py @@ -5,8 +5,12 @@ from fastapi.testclient import TestClient from sqlmodel import Session from app.core.config import settings +from app.models.division import DivisionTeamLink, DivisionTeamLinkCreate +from app.tests.conftest import EventUserHeader from app.tests.utils.division import create_random_division from app.tests.utils.association import create_random_association +from app.tests.utils.team import create_random_team +from app.tests.utils.utils import random_lower_string def test_create_division(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: @@ -189,3 +193,279 @@ def test_delete_division_no_permissions(client: TestClient, normal_user_token_he ) assert response.status_code == status.HTTP_403_FORBIDDEN assert response.json()["detail"] == "Not enough permissions" + + +def test_create_team_division_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + division = create_random_division(db) + data = { + "name": "Otters", + "division_id": str(division.id), + } + response = client.post( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == data["name"] + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_create_team_division_link_unknown_team(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + division = create_random_division(db) + data = { + "name": "Vossen", + "division_id": str(division.id), + } + response = client.post( + f"{settings.API_V1_STR}/teams/{uuid.uuid4()}/division", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Team not found" + + +def test_create_team_division_already_linked(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division1 = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division1.id), team=team) + + division2 = create_random_division(db) + data = { + "name": "Vossen", + "division_id": str(division2.id), + } + response = client.post( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["detail"] == "Team already linked to division" + + +def test_create_team_division_link_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + division = create_random_division(db) + data = { + "name": "Arenden", + "division_id": str(division.id), + } + response = client.post( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_create_team_division_link_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None: + team = create_random_team(db, event=event_user_token_headers.event) + division = create_random_division(db) + data = { + "name": "Sperwers", + "division_id": str(division.id), + } + response = client.post( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=event_user_token_headers.headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == data["name"] + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_read_team_division(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + response = client.get( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == name + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_read_team_division_unknown_team(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + response = client.get( + f"{settings.API_V1_STR}/teams/{uuid.uuid4()}/division", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Team not found" + + +def test_read_team_division_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + response = client.get( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=normal_user_token_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_read_team_division_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None: + team = create_random_team(db, event=event_user_token_headers.event) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + response = client.get( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=event_user_token_headers.headers, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == name + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_update_team_division_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + data = { + "name": "Muizenoor", + } + response = client.put( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == data["name"] + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_update_team_division_link_unknown_team(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + data = { + "name": "Leeuenbek", + } + response = client.put( + f"{settings.API_V1_STR}/teams/{uuid.uuid4()}/division", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Team not found" + + +def test_update_team_division_link_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + data = { + "name": "Geitebaard", + } + response = client.put( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_update_team_division_link_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None: + team = create_random_team(db, event=event_user_token_headers.event) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + data = { + "name": "Berenklouw", + } + response = client.put( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=event_user_token_headers.headers, + json=data, + ) + assert response.status_code == status.HTTP_200_OK + content = response.json() + assert content["name"] == data["name"] + assert content["team_id"] == str(team.id) + assert content["division_id"] == str(division.id) + + +def test_delete_team_division_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + response = client.delete( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Division deleted from team successfully" + + +def test_delete_team_division_link_unknown_team(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None: + response = client.delete( + f"{settings.API_V1_STR}/teams/{uuid.uuid4()}/division", + headers=superuser_token_headers, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Team not found" + + +def test_delete_team_division_link_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None: + team = create_random_team(db) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + response = client.delete( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=normal_user_token_headers, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "Not enough permissions" + + +def test_delete_team_division_link_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None: + team = create_random_team(db, event=event_user_token_headers.event) + + division = create_random_division(db) + name = random_lower_string() + DivisionTeamLink.create(session=db, create_obj=DivisionTeamLinkCreate(name=name, division_id=division.id), team=team) + + response = client.delete( + f"{settings.API_V1_STR}/teams/{team.id}/division", + headers=event_user_token_headers.headers, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["message"] == "Division deleted from team successfully"