15 Commits

Author SHA1 Message Date
Sebastiaan
0d6ef073df [WIP] Link hike to places 2025-11-01 01:03:34 +01:00
Sebastiaan
84d75e21ca Add base for route 2025-10-31 14:22:21 +01:00
Sebastiaan
23d7d63103 Implement basic hike functions 2025-10-24 17:48:02 +02:00
Sebastiaan
f958856e95 Add base to store flags as strings and numbes 2025-10-24 17:46:02 +02:00
Rick
79d76e780c Fixed max password length to be 100 instead of 40
Some checks failed
Generate Client / generate-client (pull_request) Has been cancelled
Lint Backend / lint-backend (pull_request) Has been cancelled
Playwright Tests / changes (pull_request) Has been cancelled
Test Backend / test-backend (pull_request) Has been cancelled
Test Docker Compose / test-docker-compose (pull_request) Has been cancelled
Add to Project / Add to project (pull_request_target) Has been cancelled
Labels / labeler (pull_request_target) Has been cancelled
Playwright Tests / test-playwright (1, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (2, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (3, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (4, 4) (pull_request) Has been cancelled
Playwright Tests / merge-playwright-reports (pull_request) Has been cancelled
Playwright Tests / alls-green-playwright (pull_request) Has been cancelled
Labels / check-labels (pull_request_target) Has been cancelled
Issue Manager / issue-manager (push) Has been cancelled
2025-10-20 21:11:46 +02:00
Sebastiaan
7d524cf04d Add members
Some checks failed
Deploy to Staging / deploy (push) Has been cancelled
Lint Backend / lint-backend (push) Has been cancelled
Playwright Tests / changes (push) Has been cancelled
Test Backend / test-backend (push) Has been cancelled
Test Docker Compose / test-docker-compose (push) Has been cancelled
Playwright Tests / test-playwright (1, 4) (push) Has been cancelled
Playwright Tests / test-playwright (2, 4) (push) Has been cancelled
Playwright Tests / test-playwright (3, 4) (push) Has been cancelled
Playwright Tests / test-playwright (4, 4) (push) Has been cancelled
Playwright Tests / merge-playwright-reports (push) Has been cancelled
Playwright Tests / alls-green-playwright (push) Has been cancelled
Generate Client / generate-client (pull_request) Has been cancelled
Lint Backend / lint-backend (pull_request) Has been cancelled
Playwright Tests / changes (pull_request) Has been cancelled
Test Backend / test-backend (pull_request) Has been cancelled
Test Docker Compose / test-docker-compose (pull_request) Has been cancelled
Add to Project / Add to project (pull_request_target) Has been cancelled
Labels / labeler (pull_request_target) Has been cancelled
Playwright Tests / test-playwright (1, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (2, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (3, 4) (pull_request) Has been cancelled
Playwright Tests / test-playwright (4, 4) (pull_request) Has been cancelled
Playwright Tests / merge-playwright-reports (pull_request) Has been cancelled
Playwright Tests / alls-green-playwright (pull_request) Has been cancelled
Labels / check-labels (pull_request_target) Has been cancelled
2025-06-17 21:56:21 +02:00
Sebastiaan
479ca1986f Add base for members 2025-06-17 21:23:14 +02:00
Sebastiaan
1e6b138873 Make private test email random 2025-06-17 20:48:43 +02:00
Sebastiaan
f2f0475859 Implement TimeZone in settings 2025-06-16 11:31:35 +02:00
Sebastiaan
7848578ebb Update rights manager for events 2025-06-13 18:49:33 +02:00
Sebastiaan
9b4d32ffff Add some other chars to test name 2025-06-13 12:50:32 +02:00
Sebastiaan
9538b9067c Add Division info to Teams 2025-06-12 23:44:57 +02:00
Sebastiaan
56b503751a Add short name to teams for lists 2025-06-12 21:50:42 +02:00
Sebastiaan
1d9e333ee0 Implement divisions 2025-06-10 21:44:02 +02:00
Sebastiaan
13a1b4dd1e Implement associations 2025-06-10 21:39:07 +02:00
42 changed files with 4567 additions and 162 deletions

2
.env
View File

@@ -38,6 +38,8 @@ POSTGRES_DB=app
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changethis
TZ=UTC
SENTRY_DSN=
# Configure these with your own Docker registry images

View File

@@ -3,10 +3,16 @@ from fastapi import APIRouter
from app.api.routes import (
events,
teams,
associations,
divisions,
members,
login,
private,
users,
utils,
hikes,
routes,
places,
)
from app.core.config import settings
@@ -18,6 +24,13 @@ api_router.include_router(utils.router)
api_router.include_router(events.router)
api_router.include_router(teams.router)
api_router.include_router(associations.router)
api_router.include_router(divisions.router)
api_router.include_router(members.router)
api_router.include_router(hikes.router)
api_router.include_router(routes.router)
api_router.include_router(places.router)
if settings.ENVIRONMENT == "local":

View File

@@ -0,0 +1,176 @@
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.division import (
Division,
DivisionsPublic,
)
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
# region # Associations / Divisions ############################################
@router.get("/{associations_id}/divisions/", response_model=DivisionsPublic)
def read_association_division(
session: SessionDep, current_user: CurrentUser, associations_id: RowId, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all association divisions.
"""
association = session.get(Association, associations_id)
if not association:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Association not found")
if not current_user.has_permission(
module=PermissionModule.ASSOCIATION,
part=PermissionPart.ADMIN,
rights=(PermissionRight.MANAGE_DIVISIONS | PermissionRight.READ),
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
count_statement = (select(func.count())
.select_from(Division)
.where(Division.association_id == association.id)
)
count = session.exec(count_statement).one()
statement = (select(Division)
.where(Division.association_id == association.id)
.offset(skip)
.limit(limit)
)
divisions = session.exec(statement).all()
return DivisionsPublic(data=divisions, count=count)
# endregion

View File

@@ -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.division import (
Division,
DivisionCreate,
DivisionUpdate,
DivisionPublic,
DivisionsPublic,
)
from app.models.user import (
PermissionModule,
PermissionPart,
PermissionRight,
)
router = APIRouter(prefix="/divisions", tags=[ApiTags.DIVISIONS])
# region # Divisions ###########################################################
@router.get("/", response_model=DivisionsPublic)
def read_divisions(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all divisions.
"""
if current_user.has_permissions(
module=PermissionModule.DIVISION,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
count_statement = select(func.count()).select_from(Division)
count = session.exec(count_statement).one()
statement = select(Division).offset(skip).limit(limit)
divisions = session.exec(statement).all()
return DivisionsPublic(data=divisions, count=count)
return DivisionsPublic(data=[], count=0)
@router.get("/{id}", response_model=DivisionPublic)
def read_division(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get division by ID.
"""
division = session.get(Division, id)
if not division:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Division not found")
if not current_user.has_permissions(
module=PermissionModule.DIVISION,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return division
@router.post("/", response_model=DivisionPublic)
def create_division(
*, session: SessionDep, current_user: CurrentUser, division_in: DivisionCreate
) -> Any:
"""
Create new division.
"""
if not current_user.has_permissions(
module=PermissionModule.DIVISION,
part=PermissionPart.ADMIN,
rights=PermissionRight.CREATE,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
division = Division.create(create_obj=division_in, session=session)
return division
@router.put("/{id}", response_model=DivisionPublic)
def update_division(
*, session: SessionDep, current_user: CurrentUser, id: RowId, division_in: DivisionUpdate
) -> Any:
"""
Update a division.
"""
division = session.get(Division, id)
if not division:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Division not found")
if not current_user.has_permissions(
module=PermissionModule.DIVISION,
part=PermissionPart.ADMIN,
rights=PermissionRight.UPDATE,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
division = Division.update(db_obj=division, in_obj=division_in, session=session)
return division
@router.delete("/{id}")
def delete_division(session: SessionDep,current_user: CurrentUser, id: RowId) -> Message:
"""
Delete a division.
"""
division = session.get(Division, id)
if not division:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Division 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(division)
session.commit()
return Message(message="Division deleted successfully")
# endregion

View File

@@ -0,0 +1,179 @@
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.hike import (
Hike,
HikeCreate,
HikeUpdate,
HikePublic,
HikesPublic,
)
from app.models.route import (
Route,
RoutesPublic,
)
from app.models.user import (
PermissionModule,
PermissionPart,
PermissionRight,
)
router = APIRouter(prefix="/hikes", tags=[ApiTags.HIKES])
# region # Hikes ########################################################
@router.get("/", response_model=HikesPublic)
def read_hikes(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all hikes.
"""
if current_user.has_permissions(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
count_statement = select(func.count()).select_from(Hike)
count = session.exec(count_statement).one()
statement = select(Hike).offset(skip).limit(limit)
hikes = session.exec(statement).all()
return HikesPublic(data=hikes, count=count)
return HikesPublic(data=[], count=0)
@router.get("/{id}", response_model=HikePublic)
def read_hike(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get hike by ID.
"""
hike = session.get(Hike, id)
if not hike:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hike not found")
if not current_user.has_permissions(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return hike
@router.post("/", response_model=HikePublic)
def create_hike(
*, session: SessionDep, current_user: CurrentUser, hike_in: HikeCreate
) -> Any:
"""
Create new hike.
"""
if not current_user.has_permissions(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=PermissionRight.CREATE,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
hike = Hike.create(create_obj=hike_in, session=session)
return hike
@router.put("/{id}", response_model=HikePublic)
def update_hike(
*, session: SessionDep, current_user: CurrentUser, id: RowId, hike_in: HikeUpdate
) -> Any:
"""
Update a hike.
"""
hike = session.get(Hike, id)
if not hike:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hike not found")
if not current_user.has_permissions(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=PermissionRight.UPDATE,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
hike = Hike.update(db_obj=hike, in_obj=hike_in, session=session)
return hike
@router.delete("/{id}")
def delete_hike(session: SessionDep,current_user: CurrentUser, id: RowId) -> Message:
"""
Delete a hike.
"""
hike = session.get(Hike, id)
if not hike:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hike not found")
if not current_user.has_permissions(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=PermissionRight.DELETE,
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
session.delete(hike)
session.commit()
return Message(message="Hike deleted successfully")
# endregion
# region # Hike / Routes #######################################################
@router.get("/{hike_id}/routes/", response_model=RoutesPublic)
def read_hike_route(
session: SessionDep,
current_user: CurrentUser,
hike_id: RowId,
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all hike routes.
"""
hike = session.get(Hike, hike_id)
if not hike:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Hike not found"
)
if not current_user.has_permission(
module=PermissionModule.HIKE,
part=PermissionPart.ADMIN,
rights=(PermissionRight.MANAGE_HIKES | PermissionRight.READ),
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
data_query = select(Route).where(
Route.hike_id == hike_id,
)
count = session.exec(select(func.count()).select_from(data_query.subquery())).one()
data = session.exec(data_query.offset(skip).limit(limit)).all()
return RoutesPublic(data=data, count=count)
# endregion

View File

@@ -0,0 +1,208 @@
from typing import Any
import sqlalchemy
from fastapi import APIRouter, HTTPException, status
from sqlmodel import func, select, and_, or_, SQLModel
from sqlalchemy.orm import joinedload
from app.api.deps import CurrentUser, SessionDep
from app.models.base import (
ApiTags,
Message,
RowId,
)
from app.models.member import (
Member,
MemberCreate,
MemberUpdate,
MemberPublic,
MembersPublic,
MemberTeamLink,
)
from app.models.event import Event, EventUserLink
from app.models.team import Team
from app.models.user import (
PermissionModule,
PermissionPart,
PermissionRight,
)
router = APIRouter(prefix="/members", tags=[ApiTags.MEMBERS])
# region # Members #############################################################
def load_member(
session: SessionDep,
current_user: CurrentUser,
id: RowId | None = None,
module: PermissionModule = PermissionModule.MEMBER,
part: PermissionPart = PermissionPart.ADMIN,
user_rights: PermissionRight | None = None,
event_rights: PermissionRight | None = PermissionRight.MANAGE_MEMBERS,
) -> Member | None:
member = session.get(Member, id)
if id and not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
no_links = True
valid = False
# Global member permissions
if current_user.has_permissions(module=module, part=part, rights=user_rights):
# Also valid for create new
valid = True
# Own member items
elif hasattr(member, "user") and member.user and member.user == current_user:
valid = True
# Event member permissions
elif hasattr(member, "team_links"):
for link in member.team_links:
team = link.team
if team and team.event:
no_links = False
if team.event.user_has_rights(user=current_user, rights=event_rights):
valid = True
break
# Not yet linked, or unlinked member
if no_links and hasattr(member, "created_by") and member.created_by == current_user.id:
valid = True
if not valid:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return member
@router.get("/", response_model=MembersPublic)
def read_members(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all members.
"""
if current_user.has_permissions(
module=PermissionModule.MEMBER,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
data_query = (
select(Member)
)
else:
data_query = (
select(Member)
.outerjoin(MemberTeamLink, MemberTeamLink.member_id == Member.id)
.outerjoin(Team, MemberTeamLink.team_id == Team.id)
.outerjoin(Event, Team.event_id == Event.id)
.outerjoin(EventUserLink, EventUserLink.event_id == Event.id)
.where(
or_(
# Own member
Member.id == current_user.member_id,
# Created by user and unlinked
and_(
Member.created_by == current_user.id,
MemberTeamLink.team_id == None
),
# Event permissions via team -> event -> EventUserLink
and_(
EventUserLink.user_id == current_user.id,
# FIXME: EventUserLink.rights.op("&")(PermissionRight.MANAGE_MEMBERS) != 0
),
)
)
)
# Cache as subquery
data_sub_query = data_query.subquery()
aliased_member = sqlalchemy.orm.aliased(Member, data_sub_query)
# Count using subquery
count = session.exec(
select(func.count()).select_from(data_sub_query)
).one()
# Paginated data query using same subquery
data = session.exec(
select(aliased_member).offset(skip).limit(limit)
).all()
return MembersPublic(count=count, data=data)
@router.get("/{id}", response_model=MemberPublic)
def read_member(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get member by ID.
"""
member = load_member(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.READ,
)
return member
@router.post("/", response_model=MemberPublic)
def create_member(
*, session: SessionDep, current_user: CurrentUser, member_in: MemberCreate
) -> Any:
"""
Create new member.
"""
load_member(
session=session,
current_user=current_user,
user_rights=PermissionRight.CREATE,
)
member = Member.create(create_obj=member_in, session=session, user=current_user)
return member
@router.put("/{id}", response_model=MemberPublic)
def update_member(
*, session: SessionDep, current_user: CurrentUser, id: RowId, member_in: MemberUpdate
) -> Any:
"""
Update a member.
"""
member = load_member(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.UPDATE,
)
member = Member.update(db_obj=member, in_obj=member_in, session=session)
return member
@router.delete("/{id}")
def delete_member(session: SessionDep,current_user: CurrentUser, id: RowId) -> Message:
"""
Delete a member.
"""
member = load_member(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.DELETE,
)
session.delete(member)
session.commit()
return Message(message="Member deleted successfully")
# endregion

View File

@@ -0,0 +1,148 @@
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.place import (
Place,
PlaceCreate,
PlaceUpdate,
PlacePublic,
PlacesPublic,
)
from app.models.user import (
PermissionModule,
PermissionPart,
PermissionRight,
)
router = APIRouter(prefix="/places", tags=[ApiTags.PLACES])
# region # Places ########################################################
@router.get("/", response_model=PlacesPublic)
def read_places(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all places.
"""
if current_user.has_permissions(
module=PermissionModule.PLACE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
count_statement = select(func.count()).select_from(Place)
count = session.exec(count_statement).one()
statement = select(Place).offset(skip).limit(limit)
places = session.exec(statement).all()
return PlacesPublic(data=places, count=count)
return PlacesPublic(data=[], count=0)
@router.get("/{id}", response_model=PlacePublic)
def read_place(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get place by ID.
"""
place = session.get(Place, id)
if not place:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Place not found"
)
if not current_user.has_permissions(
module=PermissionModule.PLACE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return place
@router.post("/", response_model=PlacePublic)
def create_place(
*, session: SessionDep, current_user: CurrentUser, place_in: PlaceCreate
) -> Any:
"""
Create new place.
"""
if not current_user.has_permissions(
module=PermissionModule.PLACE,
part=PermissionPart.ADMIN,
rights=PermissionRight.CREATE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
place = Place.create(create_obj=place_in, session=session)
return place
@router.put("/{id}", response_model=PlacePublic)
def update_place(
*, session: SessionDep, current_user: CurrentUser, id: RowId, place_in: PlaceUpdate
) -> Any:
"""
Update a place.
"""
place = session.get(Place, id)
if not place:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Place not found"
)
if not current_user.has_permissions(
module=PermissionModule.PLACE,
part=PermissionPart.ADMIN,
rights=PermissionRight.UPDATE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
place = Place.update(db_obj=place, in_obj=place_in, session=session)
return place
@router.delete("/{id}")
def delete_place(session: SessionDep, current_user: CurrentUser, id: RowId) -> Message:
"""
Delete a place.
"""
place = session.get(Place, id)
if not place:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Place not found"
)
if not current_user.has_permissions(
module=PermissionModule.PLACE,
part=PermissionPart.ADMIN,
rights=PermissionRight.DELETE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
session.delete(place)
session.commit()
return Message(message="Place deleted successfully")
# endregion

View File

@@ -17,7 +17,6 @@ router = APIRouter(tags=[ApiTags.PRIVATE], prefix="/private")
class PrivateUserCreate(BaseModel):
email: str
password: str
full_name: str
is_verified: bool = False
@@ -29,7 +28,6 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
user = User(
email=user_in.email,
full_name=user_in.full_name,
hashed_password=get_password_hash(user_in.password),
)

View File

@@ -0,0 +1,148 @@
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.route import (
Route,
RouteCreate,
RouteUpdate,
RoutePublic,
RoutesPublic,
)
from app.models.user import (
PermissionModule,
PermissionPart,
PermissionRight,
)
router = APIRouter(prefix="/routes", tags=[ApiTags.ROUTES])
# region # Routes ########################################################
@router.get("/", response_model=RoutesPublic)
def read_routes(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all routes.
"""
if current_user.has_permissions(
module=PermissionModule.ROUTE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
count_statement = select(func.count()).select_from(Route)
count = session.exec(count_statement).one()
statement = select(Route).offset(skip).limit(limit)
routes = session.exec(statement).all()
return RoutesPublic(data=routes, count=count)
return RoutesPublic(data=[], count=0)
@router.get("/{id}", response_model=RoutePublic)
def read_route(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get route by ID.
"""
route = session.get(Route, id)
if not route:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Route not found"
)
if not current_user.has_permissions(
module=PermissionModule.ROUTE,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return route
@router.post("/", response_model=RoutePublic)
def create_route(
*, session: SessionDep, current_user: CurrentUser, route_in: RouteCreate
) -> Any:
"""
Create new route.
"""
if not current_user.has_permissions(
module=PermissionModule.ROUTE,
part=PermissionPart.ADMIN,
rights=PermissionRight.CREATE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
route = Route.create(create_obj=route_in, session=session)
return route
@router.put("/{id}", response_model=RoutePublic)
def update_route(
*, session: SessionDep, current_user: CurrentUser, id: RowId, route_in: RouteUpdate
) -> Any:
"""
Update a route.
"""
route = session.get(Route, id)
if not route:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Route not found"
)
if not current_user.has_permissions(
module=PermissionModule.ROUTE,
part=PermissionPart.ADMIN,
rights=PermissionRight.UPDATE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
route = Route.update(db_obj=route, in_obj=route_in, session=session)
return route
@router.delete("/{id}")
def delete_route(session: SessionDep, current_user: CurrentUser, id: RowId) -> Message:
"""
Delete a route.
"""
route = session.get(Route, id)
if not route:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Route not found"
)
if not current_user.has_permissions(
module=PermissionModule.ROUTE,
part=PermissionPart.ADMIN,
rights=PermissionRight.DELETE,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
session.delete(route)
session.commit()
return Message(message="Route deleted successfully")
# endregion

View File

@@ -4,11 +4,14 @@ from fastapi import APIRouter, HTTPException, status
from sqlmodel import func, select
from app.api.deps import CurrentUser, SessionDep
from app.api.routes.members import load_member
from app.models.base import (
ApiTags,
Message,
RowId,
)
from app.models.member import MemberTeamLink, MemberTeamLinkCreate, MemberTeamLinkUpdate, MemberTeamLinksPublic, \
MemberTeamLinkPublic
from app.models.team import (
Team,
TeamCreate,
@@ -25,12 +28,46 @@ from app.models.user import (
PermissionPart,
PermissionRight,
)
from app.models.division import (
DivisionTeamLink,
DivisionTeamLinkCreate,
DivisionTeamLinkUpdate,
DivisionTeamLinkPublic,
)
router = APIRouter(prefix="/teams", tags=[ApiTags.TEAMS])
# region # Teams ###############################################################
def load_team(
session: SessionDep,
current_user: CurrentUser,
id: RowId | None = None,
module: PermissionModule = PermissionModule.TEAM,
part: PermissionPart = PermissionPart.ADMIN,
user_rights: PermissionRight | None = None,
event_rights: PermissionRight | None = PermissionRight.MANAGE_TEAMS,
) -> Team | None:
team = session.get(Team, id)
if id and not team:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
valid = False
if current_user.has_permissions(module=module, part=part, rights=user_rights):
# Also valid for create new
valid = True
if hasattr(team, "event"):
if team.event.user_has_rights(user=current_user, rights=event_rights):
valid = True
if not valid:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return team
@router.get("/", response_model=TeamsPublic)
def read_teams(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
@@ -44,39 +81,22 @@ def read_teams(
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
count_statement = select(func.count()).select_from(Team)
count = session.exec(count_statement).one()
statement = select(Team).offset(skip).limit(limit)
teams = session.exec(statement).all()
else:
# Only read teams that are connected to an event that the user can read
count_statement = (
select(func.count())
.select_from(Team)
.join(Event) # Join with Event to filter teams based on events
.join(EventUserLink) # Join with EventUserLink to check user permissions
.where(
EventUserLink.user_id == current_user.id,
# FIXME: (EventUserLink.rights & (PermissionRight.READ | PermissionRight.MANAGE_TEAMS)) > 0
)
)
count = session.exec(count_statement).one()
statement = (
data_query = (
select(Team)
.join(Event)
.join(EventUserLink)
)
else:
data_query = (
select(Team)
.join(EventUserLink, EventUserLink.event_id == Team.event_id)
.where(
EventUserLink.user_id == current_user.id,
# FIXME: (EventUserLink.rights & (PermissionRight.READ | PermissionRight.MANAGE_TEAMS)) > 0
)
.offset(skip)
.limit(limit)
)
teams = session.exec(statement).all()
return TeamsPublic(data=teams, count=count)
count = session.exec(select(func.count()).select_from(data_query.subquery())).one()
data = session.exec(data_query.offset(skip).limit(limit)).all()
return TeamsPublic(data=data, count=count)
@router.get("/{id}", response_model=TeamPublic)
@@ -84,20 +104,12 @@ def read_team(session: SessionDep, current_user: CurrentUser, id: RowId) -> Any:
"""
Get 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.READ,
) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_TEAMS)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
team = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.READ,
)
return team
@@ -132,21 +144,12 @@ def update_team(
"""
Update a 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.UPDATE,
) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_TEAMS)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
team = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.UPDATE,
)
# Check rights for the new event data
if team_in.event_id:
@@ -171,23 +174,228 @@ def delete_team(session: SessionDep,current_user: CurrentUser, id: RowId) -> Mes
"""
Delete 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.DELETE,
) and not (event.user_has_rights(user=current_user, rights=PermissionRight.MANAGE_TEAMS)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
team = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.DELETE,
)
session.delete(team)
session.commit()
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 = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.MANAGE_DIVISIONS,
event_rights=PermissionRight.MANAGE_DIVISIONS,
)
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 = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.MANAGE_DIVISIONS,
event_rights=PermissionRight.MANAGE_DIVISIONS,
)
if team.division_link:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Team already linked to division")
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 = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.MANAGE_DIVISIONS,
event_rights=PermissionRight.MANAGE_DIVISIONS,
)
# 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 = load_team(
session=session,
current_user=current_user,
id=id,
user_rights=PermissionRight.MANAGE_DIVISIONS,
event_rights=PermissionRight.MANAGE_DIVISIONS,
)
session.delete(team.division_link)
session.commit()
return Message(message="Division deleted from team successfully")
# endregion
# region # Teams / Members #####################################################
def load_member_link(team: Team, member_id: RowId):
link = next((link for link in team.member_links if link.member_id == member_id), None)
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
return link
@router.get("/{team_id}/members", response_model=MemberTeamLinksPublic)
def read_team_member_links(
session: SessionDep, current_user: CurrentUser, team_id: RowId, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve all member links from a teams.
"""
if current_user.has_permissions(
module=PermissionModule.TEAM,
part=PermissionPart.ADMIN,
rights=PermissionRight.READ,
):
data_query = (
select(MemberTeamLink)
.where(MemberTeamLink.team_id == team_id)
)
else:
data_query = (
select(MemberTeamLink)
.join(Team, Team.id == MemberTeamLink.team_id)
.join(EventUserLink, EventUserLink.event_id == Team.event_id)
.where(
MemberTeamLink.team_id == team_id,
EventUserLink.user_id == current_user.id,
# FIXME: (EventUserLink.rights & (PermissionRight.MANAGE_MEMBERS)) > 0
)
)
count = session.exec(select(func.count()).select_from(data_query.subquery())).one()
data = session.exec(data_query.offset(skip).limit(limit)).all()
return MemberTeamLinksPublic(data=data, count=count)
@router.get("/{team_id}/members/{member_id}", response_model=MemberTeamLinkPublic)
def read_team_member_link(session: SessionDep, current_user: CurrentUser, team_id: RowId, member_id: RowId) -> Any:
"""
Get member link by member ID.
"""
team = load_team(
session=session,
current_user=current_user,
id=team_id,
user_rights=PermissionRight.MANAGE_MEMBERS,
event_rights=PermissionRight.MANAGE_MEMBERS,
)
link = load_member_link(team=team, member_id=member_id)
return link
@router.post("/{team_id}/members", response_model=MemberTeamLinkPublic)
def create_team_member_link(
*, session: SessionDep, current_user: CurrentUser, team_id: RowId, link_in: MemberTeamLinkCreate
) -> Any:
"""
Create new team.
"""
team = load_team(
session=session,
current_user=current_user,
id=team_id,
user_rights=PermissionRight.MANAGE_MEMBERS,
event_rights=PermissionRight.MANAGE_MEMBERS,
)
# Check if user has rights for current status of the member
load_member(
session=session,
current_user=current_user,
id=link_in.member_id,
user_rights=PermissionRight.MANAGE_MEMBERS,
)
link = MemberTeamLink.create(session=session, create_obj=link_in, team=team)
return link
@router.put("/{team_id}/members/{member_id}", response_model=MemberTeamLinkPublic)
def update_team_member_link(
*, session: SessionDep, current_user: CurrentUser, team_id: RowId, member_id: RowId, link_in: MemberTeamLinkUpdate
) -> Any:
"""
Update a team member link.
"""
team = load_team(
session=session,
current_user=current_user,
id=team_id,
user_rights=PermissionRight.MANAGE_MEMBERS,
event_rights=PermissionRight.MANAGE_MEMBERS,
)
link = load_member_link(team=team, member_id=member_id)
link = MemberTeamLink.update(session=session, db_obj=link, in_obj=link_in)
return link
@router.delete("/{team_id}/members/{member_id}")
def delete_team_member_link(session: SessionDep,current_user: CurrentUser, team_id: RowId, member_id: RowId) -> Message:
"""
Delete a team member link.
"""
team = load_team(
session=session,
current_user=current_user,
id=team_id,
user_rights=PermissionRight.MANAGE_MEMBERS,
event_rights=PermissionRight.MANAGE_MEMBERS,
)
link = load_member_link(team=team, member_id=member_id)
session.delete(link)
session.commit()
return Message(message="Team member link deleted successfully")
# endregion

View File

@@ -19,6 +19,7 @@ from app.models.apikey import (
ApiKeysPublic,
)
from app.models.base import ApiTags, Message, RowId
from app.models.member import MemberPublic, MemberUpdate, Member
from app.models.user import (
PermissionModule,
PermissionPart,
@@ -195,6 +196,37 @@ def read_user_me(current_user: CurrentUser) -> Any:
return current_user
@router.get("/me/member", response_model=MemberPublic, tags=[ApiTags.MEMBERS])
def read_user_me_member(current_user: CurrentUser) -> Any:
"""
Get current user member.
"""
return current_user.member
@router.put("/me/member", response_model=MemberPublic, tags=[ApiTags.MEMBERS])
def update_user_me_member(
*, session: SessionDep, current_user: CurrentUser, member_in: MemberUpdate
) -> Any:
"""
Get current user member.
"""
member = session.get(Member, current_user.member_id)
data_obj = member_in.model_dump(exclude_unset=True)
if not member:
member = Member.model_validate(data_obj)
current_user.member_id = member.id
session.add(current_user)
else:
member.sqlmodel_update(data_obj)
session.add(member)
session.commit()
session.refresh(member)
return member
@router.delete("/me", response_model=Message)
def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
"""

View File

@@ -14,6 +14,7 @@ from pydantic import (
from pydantic_core import MultiHostUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self
from zoneinfo import ZoneInfo
def parse_cors(v: Any) -> list[str] | str:
@@ -69,6 +70,12 @@ class Settings(BaseSettings):
path=self.POSTGRES_DB,
)
tz: str = "UTC"
@property
def tz_info(self):
return ZoneInfo(self.tz)
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587

View File

@@ -5,6 +5,12 @@ from app.models.event import (
Event,
EventCreate,
)
from app.models.association import (
Association,
)
from app.models.division import (
Division,
)
from app.models.team import (
Team,
TeamCreate,
@@ -18,9 +24,24 @@ from app.models.user import (
User,
UserCreate,
)
from app.models.member import (
Member,
MemberCreate,
)
from app.models.apikey import (
ApiKey,
)
from app.models.hike import (
Hike,
HikeTeamLink,
)
from app.models.route import (
Route,
RoutePart,
)
from app.models.place import (
Place,
)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
@@ -109,6 +130,15 @@ def init_db(session: Session) -> None:
)
user = User.create(session=session, create_obj=user_in)
user.add_role(db_obj=system_admin_role, session=session)
if not user.member_id:
member_in = MemberCreate(
name="Super Admin",
)
member = Member.create(session=session, create_obj=member_in, user=user)
user.member = member
session.add(user)
session.commit()
event = session.exec(
@@ -124,11 +154,12 @@ def init_db(session: Session) -> None:
event.add_user(user, PermissionRight.ADMIN, session=session)
team = session.exec(
select(Event).where(Team.theme_name == "Laaiend vuur 熾熱的火 🔥")
select(Event).where(Team.theme_name == "Lǎàǐend vuur 熾熱的火 🔥")
).first()
if not team:
team_in = TeamCreate(
theme_name="Laaiend vuur 熾熱的火 🔥",
theme_name="Lǎàǐend vuur 熾熱的火 🔥",
short_name="1",
event_id=event.id,
)
team = Team.create(session=session, create_obj=team_in)

View File

@@ -0,0 +1,79 @@
from typing import TYPE_CHECKING
from sqlmodel import (
Session,
Relationship,
)
from . import mixin
from .base import (
BaseSQLModel,
)
if TYPE_CHECKING:
from .division import Division
# 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 -------------------------------------------------
divisions: list["Division"] = Relationship(back_populates="association", cascade_delete=True)
# --- 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

View File

@@ -4,6 +4,7 @@ from enum import Enum, IntFlag # Python 3.11 >= StrEnum
from enum import auto as auto_enum
from uuid import UUID as RowId
from sqlalchemy import TypeDecorator, Integer, VARCHAR
from sqlmodel import SQLModel
from sqlalchemy.orm import declared_attr
@@ -39,8 +40,85 @@ class DocumentedStrEnum(str, Enum):
class DocumentedIntFlag(IntFlag):
# TODO: Build DB sport to proper store flags and make it possible to store all mutations
pass
@property
def names(self) -> str:
"""
Returns a comma-separated string of all active flag names.
"""
# Exclude 0-value flags
return ",".join([flag.name for flag in type(self) if flag in self and flag.value != 0])
def __str__(self) -> str:
# Default string conversion uses the names
return self.names
# Optional: for Pydantic compatibility
def __get_pydantic_json__(self) -> str:
return self.names
class DocumentedIntFlagType(TypeDecorator):
impl = Integer
cache_ok = True
def __init__(self, enum_class: type[IntFlag], *args, **kwargs):
super().__init__(*args, **kwargs)
self.enum_class = enum_class
def process_bind_param(self, value, dialect):
"""
Convert IntFlag to integer before storing
"""
if value is None:
return None
if isinstance(value, self.enum_class):
return int(value)
if isinstance(value, int):
return value
raise ValueError(f"Invalid value for {self.enum_class.__name__}: {value!r}")
def process_result_value(self, value, dialect):
"""
Convert integer from DB back to IntFlag
"""
if value is None:
return None
return self.enum_class(value)
class DocumentedStrFlagType(TypeDecorator):
impl = VARCHAR
cache_ok = True
def __init__(self, enum_class: type[IntFlag], *args, **kwargs):
super().__init__(*args, **kwargs)
self.enum_class = enum_class
def process_bind_param(self, value, dialect):
"""
Convert IntFlag to comma-separated string of names for storing in DB.
"""
if value is None:
return None
if isinstance(value, self.enum_class):
return str(value)
raise ValueError(f"Invalid value for {self.enum_class.__name__}: {value!r}")
def process_result_value(self, value, dialect):
"""
Convert comma-separated string of names from DB back to IntFlag.
"""
if value is None or value == "":
return self.enum_class(0)
names = value.split(",")
result = self.enum_class(0)
for name in names:
try:
result |= self.enum_class[name]
except KeyError:
raise ValueError(f"Invalid flag name '{name}' for {self.enum_class.__name__}")
return result
# #############################################################################
@@ -56,6 +134,13 @@ class ApiTags(DocumentedStrEnum):
EVENTS = "Events"
TEAMS = "Teams"
ASSOCIATIONS = "Associations"
DIVISIONS = "Divisions"
MEMBERS = "Members"
HIKES = "Hikes"
ROUTES = "Routes"
PLACES = "Places"
# endregion

View File

@@ -0,0 +1,159 @@
from typing import TYPE_CHECKING
from sqlalchemy.orm.sync import update
from sqlmodel import (
Session,
Field,
Relationship,
)
from . import mixin
from .base import (
BaseSQLModel,
RowId,
)
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")
# --- 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 ###########################################################
class DivisionBase(
mixin.Name,
mixin.Contact,
mixin.ScoutingId,
BaseSQLModel,
):
association_id: RowId = Field(
foreign_key="association.id", nullable=False, ondelete="CASCADE"
)
# Properties to receive via API on creation
class DivisionCreate(DivisionBase):
pass
# Properties to receive via API on update, all are optional
class DivisionUpdate(DivisionBase):
association_id: RowId | None = Field(default=None)
class Division(mixin.RowId, DivisionBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
association: "Association" = Relationship(back_populates="divisions")
team_links: list["DivisionTeamLink"] = Relationship(back_populates="division", cascade_delete=True)
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: DivisionCreate) -> "Division":
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: "Division", in_obj: DivisionUpdate
) -> "Division":
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 DivisionPublic(mixin.RowIdPublic, DivisionBase):
association_id: RowId
class DivisionsPublic(BaseSQLModel):
data: list[DivisionPublic]
count: int
# endregion

View File

@@ -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

276
backend/app/models/hike.py Normal file
View File

@@ -0,0 +1,276 @@
from typing import TYPE_CHECKING
from sqlmodel import (
Session,
Relationship,
Field,
)
from . import mixin
from .base import (
BaseSQLModel,
DocumentedStrEnum,
DocumentedIntFlag,
DocumentedStrFlagType,
auto_enum,
RowId,
)
if TYPE_CHECKING:
from .route import Route
from .team import Team
# region # Hike ################################################################
class HikeTeamPage(DocumentedIntFlag):
ROUTE = auto_enum() # Route steps info
QUESTIONS = auto_enum() # Visible questions
VISITED_PLACES = auto_enum() # Places the team has been
CURRENT_PLACE = auto_enum() # Place where the team currently is
UNVISITED_PLACES = auto_enum() # Places the team still neat to visit
PLACES = VISITED_PLACES | CURRENT_PLACE | UNVISITED_PLACES # All the places
TRAVEL_TIME = auto_enum() # Total travel time
TRAVEL_FREE_TIME = auto_enum() # Total travel time
PLACE_TIME = auto_enum() # Total place time
CALCULATE_TIME = auto_enum() # Total time calculated based on hike settings
# TODO: Think about time between oter teams
PLACE_POINTS = auto_enum() # Points scored on a place
TOTAL_PLACE_POINTS = auto_enum() # All points got on all places
ASSIST_POINTS = auto_enum() # Minus points for assistants
# TODO: Think about place in classement
ASSIST_LOG = auto_enum() # Assisted items
ASSIST_LATTER = auto_enum() # ???
# ##############################################################################
class HikeTimeCalculation(DocumentedIntFlag):
TRAVEL_TIME = auto_enum() # Time traveling
# TODO: Think about time groups (in this model we have 2, free and non free)
TRAVEL_FREE_TIME = auto_enum() # Time that is excluded from traveling but not at a place
PLACE_TIME = auto_enum() # Time checked in at a place
TOTAL_TIME = TRAVEL_TIME | TRAVEL_FREE_TIME | PLACE_TIME
# ##############################################################################
class HikeVisitLogType(DocumentedStrEnum):
FIRST_VISIT = auto_enum()
LAST_VISIT = auto_enum()
LONGEST_VISIT = auto_enum()
SHORTEST_VISIT = auto_enum()
EACH_VISIT = auto_enum()
DISABLE_VISIT_LOGING = auto_enum()
# ##############################################################################
class HikeBase(
mixin.Name,
mixin.Contact,
BaseSQLModel,
):
tracker_interval: int | None = Field(
default=None,
nullable=True,
description="Is GPS button available, value will be the interval",
)
is_multi_day: bool = Field(
default=False,
nullable=False,
description="Show datetime in stead of time only",
)
team_page: HikeTeamPage = Field(
default=HikeTeamPage.PLACES | HikeTeamPage.ROUTE | HikeTeamPage.QUESTIONS,
nullable=False,
description="Show all the places of the route inside the teams page",
sa_type=DocumentedStrFlagType(HikeTeamPage),
)
time_calculation: HikeTimeCalculation = Field(
default=HikeTimeCalculation.TRAVEL_TIME,
nullable=False,
description="Wath should we calculate inside the total time",
sa_type=DocumentedStrFlagType(HikeTimeCalculation),
)
visit_log_type: HikeVisitLogType = Field(
default=HikeVisitLogType.FIRST_VISIT,
nullable=False,
)
min_time_points: int | None = Field(
default=0,
ge=0,
# TODO: le: max_time_points
description="Min points for time",
)
max_time_points: int | None = Field(
default=100,
ge=0, # TODO: ge > min_time_points
description="Max points for time",
)
max_question_points: int | None = Field(
default=None,
description="None: Calculate from answers (no max), Positive: Set as max for dynamic range",
)
# Properties to receive via API on creation
class HikeCreate(HikeBase):
pass
# Properties to receive via API on update, all are optional
class HikeUpdate(HikeBase):
is_multi_day: bool | None = Field(default=None) # type: ignore
team_page: HikeTeamPage | None = Field(default=None) # type: ignore
time_calculation: HikeTimeCalculation | None = Field(default=None) # type: ignore
visit_log_type: HikeVisitLogType | None = Field(default=None) # type: ignore
class Hike(mixin.RowId, HikeBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
routes: list["Route"] = Relationship(back_populates="hike", cascade_delete=True)
teams: list["HikeTeamLink"] = Relationship(back_populates="hike", cascade_delete=True)
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: HikeCreate) -> "Hike":
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: "Hike", in_obj: HikeUpdate
) -> "Hike":
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 HikePublic(mixin.RowIdPublic, HikeBase):
pass
class HikesPublic(BaseSQLModel):
data: list[HikePublic]
count: int
# endregion
# region # Hike / Team #########################################################
class HikeTeamLinkBase(
BaseSQLModel,
):
hike_id: RowId = Field(
nullable=False,
foreign_key="hike.id",
)
team_id: RowId = Field(
nullable=False,
foreign_key="team.id",
)
route_id: RowId = Field(
nullable=False,
foreign_key="route.id",
)
start_place_id: RowId | None = Field(
default=None,
nullable=True,
foreign_key="place.id",
)
# Properties to receive via API on creation
class HikeTeamLinkCreate(HikeTeamLinkBase):
pass
# Properties to receive via API on update, all are optional
class HikeTeamLinkUpdate(HikeTeamLinkBase):
hike_id: RowId | None = Field(default=None, nullable=True) # type: ignore
team_id: RowId | None = Field(default=None, nullable=True) # type: ignore
route_id: RowId | None = Field(default=None, nullable=True) # type: ignore
class HikeTeamLink(mixin.RowId, HikeTeamLinkBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
team: "Team" = Relationship(back_populates="hike_links")
hike: "Hike" = Relationship(back_populates="teams")
route: "Route" = Relationship(back_populates="teams")
# start_place: "Place" = Relationship(back_populates="teams")
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: HikeTeamLinkCreate) -> "HikeTeamLink":
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: "HikeTeamLink", in_obj: HikeTeamLinkUpdate
) -> "HikeTeamLink":
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 HikeTeamLinkPublic(mixin.RowIdPublic, HikeTeamLinkBase):
pass
class HikeTeamLinksPublic(BaseSQLModel):
data: list[HikeTeamLinkPublic]
count: int
# endregion

View File

@@ -0,0 +1,191 @@
from typing import TYPE_CHECKING, Optional
from sqlmodel import (
Session,
Field,
Relationship,
)
from . import mixin
from .base import (
BaseSQLModel,
DocumentedIntFlag,
auto_enum,
RowId,
)
if TYPE_CHECKING:
from .user import User
from .team import Team
# region # Member / Teams ######################################################
class MemberRank(DocumentedIntFlag):
TEAM_MEMBER = auto_enum()
TEAM_ASSISTANT_LEADER = auto_enum()
TEAM_LEADER = auto_enum()
DIVISION_LEADER = auto_enum()
VOLUNTEER = auto_enum()
# ##############################################################################
# Shared properties
class MemberTeamLinkBase(BaseSQLModel):
rank: MemberRank = Field(default=MemberRank.TEAM_MEMBER, nullable=False)
# Properties to receive via API on creation
class MemberTeamLinkCreate(MemberTeamLinkBase):
member_id: RowId = Field(default=None, nullable=False)
# Properties to receive via API on update, all are optional
class MemberTeamLinkUpdate(MemberTeamLinkBase):
pass
# Database model, database table inferred from class name
class MemberTeamLink(MemberTeamLinkBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
member_id: RowId = Field(
foreign_key="member.id",
primary_key=True,
nullable=False,
ondelete="CASCADE",
)
team_id: RowId = Field(
foreign_key="team.id",
primary_key=True,
nullable=False,
ondelete="CASCADE",
)
member: "Member" = Relationship(back_populates="team_links")
team: "Team" = Relationship(back_populates="member_links")
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: MemberTeamLinkCreate, team: "Team") -> "MemberTeamLink":
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: "MemberTeamLink", in_obj: MemberTeamLinkUpdate
) -> "MemberTeamLink":
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
class MemberTeamLinkPublic(MemberTeamLinkBase):
member_id: RowId
team_id: RowId
class MemberTeamLinksPublic(BaseSQLModel):
data: list[MemberTeamLinkPublic]
count: int
# endregion
# region # Member ##############################################################
# Shared properties
class MemberBase(
mixin.Name,
mixin.Contact,
mixin.ScoutingId,
mixin.Comment,
mixin.Allergy,
mixin.Birthday,
mixin.Canceled,
BaseSQLModel,
):
pass
# Properties to receive via API on creation
class MemberCreate(MemberBase):
pass
# Properties to receive via API on update, all are optional
class MemberUpdate(MemberBase):
pass
# Database model, database table inferred from class name
class Member(mixin.RowId, mixin.Created, MemberBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
user: Optional["User"] = Relationship(
back_populates="member",
sa_relationship_kwargs={"foreign_keys": "User.member_id"},
)
team_links: list["MemberTeamLink"] = Relationship(back_populates="member", cascade_delete=True)
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: MemberCreate, user: Optional["User"] = None) -> "Member":
data_obj = create_obj.model_dump(exclude_unset=True)
extra_fields = {}
if user:
extra_fields["created_by"] = user.id
db_obj = cls.model_validate(data_obj, update=extra_fields)
session.add(db_obj)
session.commit()
session.refresh(db_obj)
return db_obj
@classmethod
def update(
cls, *, session: Session, db_obj: "Member", in_obj: MemberUpdate
) -> "Member":
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 MemberPublic(mixin.RowIdPublic, MemberBase):
# TODO: Return user_id
pass
class MembersPublic(BaseSQLModel):
data: list[MemberPublic]
count: int
# endregion

View File

@@ -1,5 +1,6 @@
import uuid
from datetime import datetime
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, EmailStr
from sqlmodel import (
@@ -7,12 +8,17 @@ from sqlmodel import (
)
from .base import RowId as RowIdType
from ..core.config import settings
class Name(BaseModel):
name: str | None = Field(default=None, nullable=False, unique=False, max_length=255)
class NameOveride(BaseModel):
name_overide: str | None = Field(default=None, nullable=True, unique=False, max_length=255)
class FullName(BaseModel):
full_name: str | None = Field(default=None, nullable=True, max_length=255)
@@ -25,6 +31,19 @@ class ThemeNameUpdate(ThemeName):
theme_name: str | None = Field(default=None, max_length=255)
class ShortName(BaseModel):
short_name: str = Field(index=True, max_length=8)
class ShortNameUpdate(ShortName):
#TODO: Waarom is deze verplicht ???
short_name: str | None = Field(default=None, nullable=True, max_length=8)
class ShortNameOveride(ShortName):
short_name_overide: str | None = Field(default=None, nullable=True, max_length=8)
class Contact(BaseModel):
contact: str | None = Field(default=None, nullable=True, max_length=255)
@@ -54,7 +73,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):
@@ -62,7 +81,7 @@ class Password(BaseModel):
class PasswordUpdate(Password):
password: str | None = Field(default=None, min_length=8, max_length=40)
password: str | None = Field(default=None, min_length=8, max_length=100)
class RowId(BaseModel):
@@ -81,6 +100,14 @@ class Description(BaseModel):
description: str | None = Field(default=None, nullable=True, max_length=512)
class Comment(BaseModel):
comment: str | None = Field(default=None, nullable=True, max_length=512)
class Allergy(BaseModel):
allergy: str | None = Field(default=None, nullable=True, max_length=512)
class StartEndDate:
start_at: datetime | None = Field(default=None, nullable=True)
end_at: datetime | None = Field(default=None, nullable=True)
@@ -96,3 +123,24 @@ class CheckInCheckOut(BaseModel):
checkout_at: datetime | None = Field(default=None, nullable=True)
class Birthday(BaseModel):
birthday_at: date | None = Field(default=None, nullable=True)
class Created(BaseModel):
created_at: datetime | None = Field(nullable=False, default_factory=lambda: datetime.now(settings.tz_info))
created_by: RowIdType | None = Field(default=None, nullable=True, foreign_key="user.id", ondelete="SET NULL")
class Location(BaseModel):
latitude: Decimal | None = Field(default=None, nullable=True, max_digits=11, decimal_places=8, description="decimal degrees")
longitude: Decimal | None = Field(default=None, nullable=True, max_digits=11, decimal_places=8, description="decimal degrees")
class LocationWithRadius(Location):
radius: int | None = Field(default=None, nullable=True, description="Radius in meters")
class QuestionInfo:
question_file: str | None = Field(default=None, nullable=True, max_length=255)
answer_file: str | None = Field(default=None, nullable=True, max_length=255)

179
backend/app/models/place.py Normal file
View File

@@ -0,0 +1,179 @@
from datetime import timedelta
from typing import TYPE_CHECKING
from sqlalchemy import Interval
from sqlmodel import (
Session,
Relationship,
Field,
)
from . import mixin
from .base import (
BaseSQLModel,
DocumentedStrEnum,
auto_enum,
)
if TYPE_CHECKING:
from .route import Route, RoutePart
# region # Place ###############################################################
class PlaceType(DocumentedStrEnum):
START = auto_enum() # Only check in (make GPS available) (TODO: determine based on route)
PLACE = auto_enum() # Check in / Check out (subtract time between in&out)
TAG = auto_enum() # Instant in&out at the same time
FINISH = auto_enum() # Only check out (and disable GPS) (TODO: determine based on route)
# ##############################################################################
class VisitedCountType(DocumentedStrEnum):
NONE = auto_enum()
ONE_VISIT = auto_enum()
EACH_VISIT = auto_enum()
# ##############################################################################
class PlaceBase(
mixin.Name,
mixin.ShortName,
mixin.Contact,
mixin.Description,
mixin.LocationWithRadius,
mixin.QuestionInfo,
BaseSQLModel,
):
place_type: PlaceType = Field(
default=PlaceType.PLACE,
nullable=False,
)
max_points: int | None = Field(
default=None,
ge=0,
description="Max points for this place, None will disable scoring at this place",
)
visited_points: int | None = Field(
default=None,
ge=0,
description="Visited points for this place, None will disable this function",
)
visited_count_type: VisitedCountType = Field(
default=VisitedCountType.NONE,
)
skipped_penalty_points: int | None = Field(
default=None,
ge=0,
description="Skipped penalty for this place, amount will be subtracted, None will disable this function",
)
place_time: timedelta | None = Field(
default=None,
nullable=True,
description="Time for question at this place during testing.",
sa_type=Interval,
)
# Properties to receive via API on creation
class PlaceCreate(PlaceBase):
pass
# Properties to receive via API on update, all are optional
class PlaceUpdate(
PlaceBase,
mixin.ShortNameUpdate,
):
place_type: PlaceType | None = Field(default=None) # type: ignore
visited_count_type: VisitedCountType | None = Field(default=None) # type: ignore
class Place(mixin.RowId, PlaceBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
routes: list["Route"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "or_("
"Place.id==Route.start_id,"
"Place.id==Route.finish_id,"
"Place.id==RoutePart.place_id,"
"Place.id==RoutePart.to_place_id,"
")",
"foreign_keys": "["
"Route.start_id,"
"Route.finish_id,"
"RoutePart.place_id,"
"RoutePart.to_place_id"
"]",
"viewonly": True,
},
)
next_places: list["Place"] = Relationship(
back_populates="previous_place",
sa_relationship_kwargs={
"secondary": "route_part",
"primaryjoin": "Place.id == RoutePart.place_id",
"secondaryjoin": "Place.id == RoutePart.to_place_id",
"viewonly": True,
},
)
previous_place: list["Place"] = Relationship(
back_populates="next_places",
sa_relationship_kwargs={
"secondary": "route_part",
"primaryjoin": "Place.id == RoutePart.to_place_id",
"secondaryjoin": "Place.id == RoutePart.place_id",
"viewonly": True,
},
)
route_parts: list["RoutePart"] = Relationship(back_populates="place", sa_relationship_kwargs={"foreign_keys": "[RoutePart.place_id]"})
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: PlaceCreate) -> "Place":
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: "Place", in_obj: PlaceUpdate
) -> "Place":
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 PlacePublic(mixin.RowIdPublic, PlaceBase):
pass
class PlacesPublic(BaseSQLModel):
data: list[PlacePublic]
count: int
# endregion

283
backend/app/models/route.py Normal file
View File

@@ -0,0 +1,283 @@
from datetime import timedelta
from typing import TYPE_CHECKING
from sqlalchemy import Interval
from sqlmodel import (
Session,
Relationship,
Field,
)
from . import mixin
from .base import (
BaseSQLModel,
DocumentedStrEnum,
DocumentedStrFlagType,
auto_enum,
RowId,
)
from .hike import (
Hike,
HikeTimeCalculation,
HikeTeamLink,
)
if TYPE_CHECKING:
from .place import Place
# region # Route ###############################################################
class RouteType(DocumentedStrEnum):
START_FINISH = auto_enum() # Start at the start and end at the finish
CIRCULAR = auto_enum() # Start some ware, finish at the last new place
CIRCULAR_BACK_TO_START = auto_enum() # Start and finish on the same random place (CIRCULAR + next to start)
# ##############################################################################
class RouteBase(
mixin.Name,
mixin.Contact,
BaseSQLModel,
):
route_type: RouteType = Field(
default=RouteType.START_FINISH,
nullable=False,
)
time_calculation_override: HikeTimeCalculation | None = Field(
default=None,
nullable=True,
description="Should this route be calculated different then the main hike",
sa_type=DocumentedStrFlagType(HikeTimeCalculation),
)
min_time: timedelta | None = Field(
default=None,
description="Min time correction, None = min of all, positive is used as 0:00, negative is subtracted from min of all time",
sa_type=Interval,
)
max_time: timedelta | None = Field(
default=None,
description="Max time correction, None = max of all, positive is used as max, negative is subtracted from max of all time",
sa_type=Interval,
)
hike_id: RowId = Field(
nullable=False,
foreign_key="hike.id",
)
start_id: RowId | None = Field(
default=None,
nullable=True,
foreign_key="place.id",
ondelete="SET NULL",
description="Start place.id of the route, None for random in CIRCULAR or CIRCULAR_BACK_TO_START",
)
finish_id: RowId | None = Field(
default=None,
nullable=True,
foreign_key="place.id",
ondelete="SET NULL",
description="Finish place.id of the route, None for random or decremented",
)
# Properties to receive via API on creation
class RouteCreate(RouteBase):
pass
# Properties to receive via API on update, all are optional
class RouteUpdate(RouteBase):
route_type: RouteType | None = Field(default=None) # type: ignore
hike_id: RowId | None = Field(default=None) # type: ignore
class Route(mixin.RowId, RouteBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
hike: "Hike" = Relationship(back_populates="routes")
teams: list["HikeTeamLink"] = Relationship(back_populates="route")
start: "Place" = Relationship(sa_relationship_kwargs={"foreign_keys": "[Route.start_id]"})
finish: "Place" = Relationship(sa_relationship_kwargs={"foreign_keys": "[Route.finish_id]"})
places: list["Place"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "or_("
"Place.id==Route.start_id,"
"Place.id==Route.finish_id,"
"Place.id==RoutePart.place_id,"
"Place.id==RoutePart.to_place_id,"
")",
"foreign_keys": "["
"Route.start_id,"
"Route.finish_id,"
"RoutePart.place_id,"
"RoutePart.to_place_id"
"]",
"viewonly": True,
},
)
route_parts: list["RoutePart"] = Relationship(back_populates="route")
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: RouteCreate) -> "Route":
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: "Route", in_obj: RouteUpdate
) -> "Route":
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 RoutePublic(mixin.RowIdPublic, RouteBase):
pass
class RoutesPublic(BaseSQLModel):
data: list[RoutePublic]
count: int
# endregion
# region # Route / Place link ##################################################
class RoutePartBase(
mixin.NameOveride,
mixin.ShortNameOveride,
BaseSQLModel,
):
route_id: RowId = Field(
nullable=False,
foreign_key="route.id",
)
place_id: RowId = Field(
nullable=False,
foreign_key="place.id",
)
to_place_id: RowId = Field(
nullable=False,
foreign_key="place.id",
)
travel_time: timedelta | None = Field(
default=None,
nullable=True,
description="Time to travel during test walk, only used for information",
sa_type=Interval,
)
travel_distance: int | None = Field(
default=None,
nullable=True,
ge=0,
description="Distance in meters to travel, only used for information",
)
# TODO: Convert to time groups
free_walk_time: bool | None = Field(
default=False,
description="If true, travel time is not add to calculated for travel time",
)
# Properties to receive via API on creation
class RoutePartBaseCreate(RoutePartBase):
pass
# Properties to receive via API on update, all are optional
class RoutePartBaseUpdate(RoutePartBase):
route_id: RowId | None = Field(default=None) # type: ignore
place_id: RowId | None = Field(default=None) # type: ignore
to_place_id: RowId | None = Field(default=None) # type: ignore
free_walk_time: bool | None = Field(default=None) # type: ignore
class RoutePart(mixin.RowId, RoutePartBase, table=True):
# --- database only items --------------------------------------------------
# --- read only items ------------------------------------------------------
# --- back_populates links -------------------------------------------------
route: "Route" = Relationship(back_populates="route_parts")
place: "Place" = Relationship(back_populates="route_parts", sa_relationship_kwargs={"foreign_keys": "[RoutePart.place_id]"})
place_to: "Place" = Relationship(sa_relationship_kwargs={"foreign_keys": "[RoutePart.to_place_id]"})
places: list["Place"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "or_("
"Place.id==RoutePart.place_id,"
"Place.id==RoutePart.to_place_id,"
")",
"foreign_keys": "["
"RoutePart.place_id,"
"RoutePart.to_place_id"
"]",
"viewonly": True,
},
)
# --- CRUD actions ---------------------------------------------------------
@classmethod
def create(cls, *, session: Session, create_obj: RouteCreate) -> "RoutePart":
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: "RoutePart", in_obj: RouteUpdate
) -> "RoutePart":
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 RoutePartPublic(mixin.RowIdPublic, RoutePartBase):
pass
class RoutePartsPublic(BaseSQLModel):
data: list[RoutePartPublic]
count: int
# endregion

View File

@@ -14,12 +14,16 @@ from .base import (
if TYPE_CHECKING:
from .event import Event
from .division import DivisionTeamLink
from .member import MemberTeamLink
from .hike import HikeTeamLink
# region # Team ################################################################
class TeamBase(
mixin.ThemeName,
mixin.ShortName,
mixin.CheckInCheckOut,
mixin.Canceled,
BaseSQLModel
@@ -27,9 +31,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
@@ -38,7 +39,7 @@ class TeamCreate(TeamBase):
# Properties to receive via API on update, all are optional
class TeamUpdate(mixin.ThemeNameUpdate, TeamBase):
class TeamUpdate(mixin.ThemeNameUpdate, mixin.ShortNameUpdate, TeamBase):
event_id: RowId | None = Field(default=None)
@@ -48,8 +49,10 @@ 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)
member_links: list["MemberTeamLink"] = Relationship(back_populates="team", cascade_delete=True)
hike_links: list["HikeTeamLink"] = Relationship(back_populates="team", cascade_delete=True)
# --- CRUD actions ---------------------------------------------------------
@classmethod

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
from pydantic import EmailStr, field_validator
from sqlmodel import Field, Relationship, Session, select
@@ -17,6 +17,7 @@ from .base import (
if TYPE_CHECKING:
from .apikey import ApiKey
from .event import EventUserLink
from .member import Member
# region # User ################################################################
@@ -27,6 +28,13 @@ class PermissionModule(DocumentedStrEnum):
USER = auto_enum()
EVENT = auto_enum()
TEAM = auto_enum()
ASSOCIATION = auto_enum()
DIVISION = auto_enum()
MEMBER = auto_enum()
HIKE = auto_enum()
ROUTE = auto_enum()
PLACE = auto_enum()
class PermissionPart(DocumentedStrEnum):
@@ -42,8 +50,21 @@ class PermissionRight(DocumentedIntFlag):
MANAGE_USERS = auto_enum()
MANAGE_TEAMS = auto_enum()
MANAGE_DIVISIONS = auto_enum()
MANAGE_MEMBERS = auto_enum()
ADMIN = CREATE | READ | UPDATE | DELETE | MANAGE_USERS | MANAGE_TEAMS
MANAGE_HIKES = auto_enum()
ADMIN = ( CREATE
| READ
| UPDATE
| DELETE
| MANAGE_USERS
| MANAGE_TEAMS
| MANAGE_DIVISIONS
| MANAGE_MEMBERS
| MANAGE_HIKES
)
# ##############################################################################
@@ -72,13 +93,16 @@ class UserRoleLink(BaseSQLModel, table=True):
class UserBase(
mixin.UserName,
mixin.Email,
mixin.FullName,
mixin.ScoutingId,
mixin.IsActive,
mixin.IsVerified,
BaseSQLModel,
):
pass
member_id: RowId | None = Field(
default=None,
foreign_key="member.id",
nullable=True,
ondelete="SET NULL",
)
# Properties to receive via API on creation
@@ -86,7 +110,7 @@ class UserCreate(mixin.Password, UserBase):
pass
class UserRegister(mixin.Password, mixin.FullName, BaseSQLModel):
class UserRegister(mixin.Password, BaseSQLModel):
email: EmailStr = Field(max_length=255)
@@ -95,13 +119,13 @@ class UserUpdate(mixin.EmailUpdate, mixin.PasswordUpdate, UserBase):
pass
class UserUpdateMe(mixin.FullName, mixin.EmailUpdate, BaseSQLModel):
class UserUpdateMe(mixin.EmailUpdate, BaseSQLModel):
pass
class UpdatePassword(BaseSQLModel):
current_password: str = Field(min_length=8, max_length=40)
new_password: str = Field(min_length=8, max_length=40)
current_password: str = Field(min_length=8, max_length=100)
new_password: str = Field(min_length=8, max_length=100)
# Database model, database table inferred from class name
@@ -110,6 +134,10 @@ class User(mixin.RowId, UserBase, table=True):
hashed_password: str
# --- back_populates links -------------------------------------------------
member: Optional["Member"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"foreign_keys": "User.member_id"},
)
api_keys: list["ApiKey"] = Relationship(back_populates="user", cascade_delete=True)
# --- many-to-many links ---------------------------------------------------
@@ -280,7 +308,7 @@ class TokenPayload(BaseSQLModel):
class NewPassword(BaseSQLModel):
token: str
new_password: str = Field(min_length=8, max_length=40)
new_password: str = Field(min_length=8, max_length=100)
# endregion

View File

@@ -0,0 +1,222 @@
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
from app.tests.utils.division import create_random_division
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"
def test_read_association_divisions(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
association = create_random_association(db)
create_random_division(db, association=association)
create_random_division(db, association=association)
response = client.get(
f"{settings.API_V1_STR}/associations/{association.id}/divisions",
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_association_divisions_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/associations/{uuid.uuid4()}/divisions",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Association not found"
def test_read_association_divisions_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
association = create_random_association(db)
create_random_division(db, association=association)
response = client.get(
f"{settings.API_V1_STR}/associations/{association.id}/divisions",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"

View File

@@ -0,0 +1,471 @@
import uuid
from fastapi import status
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:
association = create_random_association(db)
data = {
"name": "Verkenners",
"scouting_id": "122314",
"association_id": str(association.id),
}
response = client.post(
f"{settings.API_V1_STR}/divisions/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["name"] == data["name"]
assert content["scouting_id"] == data["scouting_id"]
assert content["association_id"] == str(association.id)
assert "contact" in content
assert "id" in content
def test_create_division_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
association = create_random_association(db)
data = {
"name": "Padvinsters",
"contact": "-",
"scouting_id": "122323",
"association_id": str(association.id),
}
response = client.post(
f"{settings.API_V1_STR}/divisions/",
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_division(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
response = client.get(
f"{settings.API_V1_STR}/divisions/{division.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(division.id)
assert content["name"] == division.name
assert content["contact"] == division.contact
assert content["scouting_id"] == division.scouting_id
assert content["association_id"] == str(division.association_id)
def test_read_division_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/divisions/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Division not found"
def test_read_division_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
response = client.get(
f"{settings.API_V1_STR}/divisions/{division.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_divisions(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
create_random_division(db)
create_random_division(db)
response = client.get(
f"{settings.API_V1_STR}/divisions/",
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_divisions_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
create_random_division(db)
create_random_division(db)
response = client.get(
f"{settings.API_V1_STR}/divisions/",
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_division(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
data = {
"name": "Updated name",
"contact": "Updated contact",
}
response = client.put(
f"{settings.API_V1_STR}/divisions/{division.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(division.id)
assert content["name"] == data["name"]
assert content["contact"] == data["contact"]
assert content["scouting_id"] == division.scouting_id
assert content["association_id"] == str(division.association_id)
def test_update_division_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}/divisions/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Division not found"
def test_update_division_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
data = {
"name": "No permissions",
"contact": "No permissions",
}
response = client.put(
f"{settings.API_V1_STR}/divisions/{division.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_division(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
response = client.delete(
f"{settings.API_V1_STR}/divisions/{division.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Division deleted successfully"
def test_delete_division_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
response = client.delete(
f"{settings.API_V1_STR}/divisions/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Division not found"
def test_delete_division_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
division = create_random_division(db)
response = client.delete(
f"{settings.API_V1_STR}/divisions/{division.id}",
headers=normal_user_token_headers,
)
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"

View File

@@ -0,0 +1,223 @@
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.hike import create_random_hike
from app.tests.utils.route import create_random_route
def test_create_hike(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
data = {
"name": "RSW Maasdelta 2026",
"contact": "Sebas",
}
response = client.post(
f"{settings.API_V1_STR}/hikes/",
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 "id" in content
def test_create_hike_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
data = {
"name": "No permissions",
"contact": "No permissions",
}
response = client.post(
f"{settings.API_V1_STR}/hikes/",
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_hike(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
response = client.get(
f"{settings.API_V1_STR}/hikes/{hike.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(hike.id)
assert content["name"] == hike.name
assert content["contact"] == hike.contact
def test_read_hike_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/hikes/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Hike not found"
def test_read_hike_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
response = client.get(
f"{settings.API_V1_STR}/hikes/{hike.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_hikes(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
create_random_hike(db)
create_random_hike(db)
response = client.get(
f"{settings.API_V1_STR}/hikes/",
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_hikes_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
create_random_hike(db)
create_random_hike(db)
response = client.get(
f"{settings.API_V1_STR}/hikes/",
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_hike(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
data = {
"name": "Updated name",
"contact": "Updated contact",
}
response = client.put(
f"{settings.API_V1_STR}/hikes/{hike.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(hike.id)
assert content["name"] == data["name"]
assert content["contact"] == data["contact"]
def test_update_hike_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}/hikes/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Hike not found"
def test_update_hike_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
data = {
"name": "No permissions",
"contact": "No permissions",
}
response = client.put(
f"{settings.API_V1_STR}/hikes/{hike.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_hike(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
response = client.delete(
f"{settings.API_V1_STR}/hikes/{hike.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Hike deleted successfully"
def test_delete_hike_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
response = client.delete(
f"{settings.API_V1_STR}/hikes/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Hike not found"
def test_delete_hike_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
response = client.delete(
f"{settings.API_V1_STR}/hikes/{hike.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_hike_routes(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
hike = create_random_hike(db)
create_random_route(db, hike=hike)
create_random_route(db, hike=hike)
response = client.get(
f"{settings.API_V1_STR}/hikes/{hike.id}/routes",
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_hike_routes_not_found(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
response = client.get(
f"{settings.API_V1_STR}/hikes/{uuid.uuid4()}/routes",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Hike not found"
def test_read_hike_routes_no_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
hike = create_random_hike(db)
create_random_route(db, hike=hike)
response = client.get(
f"{settings.API_V1_STR}/hikes/{hike.id}/routes",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"

View File

@@ -0,0 +1,396 @@
import uuid
from fastapi import status
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.core.config import settings
from app.models.member import Member, MemberTeamLink, MemberTeamLinkCreate, MemberRank
from app.models.user import User
from app.tests.conftest import EventUserHeader
from app.tests.utils.team import create_random_team
from app.tests.utils.member import create_random_member
from app.tests.utils.user import create_random_user, authentication_token_from_user
def test_create_member(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
data = {
"name": "John Do",
"scouting_id": "12345678",
"allergy": "Do not feed Tomatoes",
}
response = client.post(
f"{settings.API_V1_STR}/members/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["name"] == data["name"]
assert content["scouting_id"] == data["scouting_id"]
assert content["allergy"] == data["allergy"]
assert "contact" in content
assert "comment" in content
assert "birthday_at" in content
assert "canceled_at" in content
assert "canceled_reason" in content
assert "id" in content
member_query = select(Member).where(Member.id == content["id"])
member_db = db.exec(member_query).first()
assert member_db
assert member_db.name == data["name"]
assert member_db.scouting_id == data["scouting_id"]
assert member_db.allergy == data["allergy"]
user = User.get_by_email(session=db, email=settings.FIRST_SUPERUSER)
assert member_db.created_by == user.id
def test_create_member_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
data = {
"name": "No John",
"scouting_id": "0",
"comment": "Is not existing",
}
response = client.post(
f"{settings.API_V1_STR}/members/",
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_member(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
member = create_random_member(db)
response = client.get(
f"{settings.API_V1_STR}/members/{member.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(member.id)
assert content["name"] == member.name
assert content["contact"] == member.contact
assert content["scouting_id"] == member.scouting_id
assert content["comment"] == member.comment
assert content["allergy"] == member.allergy
assert content["birthday_at"] == member.birthday_at
assert content["canceled_at"] == member.canceled_at
assert content["canceled_reason"] == member.canceled_reason
def test_read_member_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None:
team = create_random_team(db, event=event_user_token_headers.event)
member = create_random_member(db)
link = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link, team=team)
response = client.get(
f"{settings.API_V1_STR}/members/{member.id}",
headers=event_user_token_headers.headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(member.id)
assert content["name"] == member.name
assert content["contact"] == member.contact
assert content["scouting_id"] == member.scouting_id
assert content["comment"] == member.comment
assert content["allergy"] == member.allergy
assert content["birthday_at"] == member.birthday_at
assert content["canceled_at"] == member.canceled_at
assert content["canceled_reason"] == member.canceled_reason
def test_read_member_own_user(client: TestClient, db: Session) -> None:
member = create_random_member(db)
user = create_random_user(db)
user.member_id = member.id
db.add(user)
db.commit()
response = client.get(
f"{settings.API_V1_STR}/members/{member.id}",
headers=authentication_token_from_user(client=client, db=db, user=user),
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(member.id)
assert content["name"] == member.name
assert content["contact"] == member.contact
assert content["scouting_id"] == member.scouting_id
assert content["comment"] == member.comment
assert content["allergy"] == member.allergy
assert content["birthday_at"] == member.birthday_at
assert content["canceled_at"] == member.canceled_at
assert content["canceled_reason"] == member.canceled_reason
def test_read_member_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/members/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Member not found"
def test_read_member_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
member = create_random_member(db)
response = client.get(
f"{settings.API_V1_STR}/members/{member.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_members(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
create_random_member(db)
create_random_member(db)
response = client.get(
f"{settings.API_V1_STR}/members/",
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_members_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None:
team = create_random_team(db, event=event_user_token_headers.event)
member = create_random_member(db)
link = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link, team=team)
response = client.get(
f"{settings.API_V1_STR}/members/",
headers=event_user_token_headers.headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert "count" in content
assert content["count"] >= 1
assert "data" in content
assert isinstance(content["data"], list)
assert len(content["data"]) <= content["count"]
def test_read_members_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
create_random_member(db)
create_random_member(db)
response = client.get(
f"{settings.API_V1_STR}/divisions/",
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_read_members_self_created(client: TestClient, db: Session) -> None:
user = create_random_user(db)
member = create_random_member(db)
member.created_by = user.id
db.add(member)
db.commit()
response = client.get(
f"{settings.API_V1_STR}/members/",
headers=authentication_token_from_user(client=client, db=db, user=user),
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert "count" in content
assert content["count"] == 1
assert "data" in content
assert isinstance(content["data"], list)
assert len(content["data"]) <= content["count"]
def test_update_member(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
member = create_random_member(db)
member_id = member.id
data = {
"name": "Updated name",
"contact": "Updated contact",
}
response = client.put(
f"{settings.API_V1_STR}/members/{member_id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(member_id)
assert content["name"] == data["name"]
assert content["contact"] == data["contact"]
member_query = select(Member).where(Member.id == member_id)
member_db = db.exec(member_query).first()
assert member_db
db.refresh(member_db)
assert member_db.name == data["name"]
assert member_db.contact == data["contact"]
assert content["scouting_id"] == member_db.scouting_id
assert content["comment"] == member_db.comment
assert content["allergy"] == member_db.allergy
assert content["birthday_at"] == member_db.birthday_at
assert content["canceled_at"] == member_db.canceled_at
assert content["canceled_reason"] == member_db.canceled_reason
def test_delete_member(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
member = create_random_member(db)
member_id = member.id
response = client.delete(
f"{settings.API_V1_STR}/members/{member_id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Member deleted successfully"
member_query = select(Member).where(Member.id == member_id)
member_db = db.exec(member_query).first()
assert member_db is None
def test_create_team_member_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
member = create_random_member(db)
data = {
"member_id": str(member.id),
"rank": MemberRank.TEAM_MEMBER
}
response = client.post(
f"{settings.API_V1_STR}/teams/{team.id}/members",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["member_id"] == str(member.id)
assert content["team_id"] == str(team.id)
assert content["rank"] == data["rank"]
def test_read_team_member_links(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
member = create_random_member(db)
link_in = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link_in, team=team)
response = client.get(
f"{settings.API_V1_STR}/teams/{team.id}/members",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert "count" in content and content["count"] >= 1
assert "data" in content and isinstance(content["data"], list)
assert any(link["member_id"] == str(member.id) for link in content["data"])
def test_read_team_member_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
member = create_random_member(db)
link_in = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link_in, team=team)
response = client.get(
f"{settings.API_V1_STR}/teams/{team.id}/members/{member.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["member_id"] == str(member.id)
assert content["team_id"] == str(team.id)
def test_read_team_member_link_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
response = client.get(
f"{settings.API_V1_STR}/teams/{team.id}/members/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Member not found"
def test_read_team_member_link_event_user(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None:
team = create_random_team(db, event=event_user_token_headers.event)
member = create_random_member(db)
link_in = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link_in, team=team)
response = client.get(
f"{settings.API_V1_STR}/teams/{team.id}/members/{member.id}",
headers=event_user_token_headers.headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["member_id"] == str(member.id)
assert content["team_id"] == str(team.id)
# TODO: Add event user test
# TODO: Add created_by test
def test_update_team_member_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
member = create_random_member(db)
link_in = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link_in, team=team)
data = {"rank": MemberRank.TEAM_LEADER}
response = client.put(
f"{settings.API_V1_STR}/teams/{team.id}/members/{member.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["rank"] == data["rank"]
def test_delete_team_member_link(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
member = create_random_member(db)
link_in = MemberTeamLinkCreate(member_id=member.id, rank=MemberRank.TEAM_MEMBER)
MemberTeamLink.create(session=db, create_obj=link_in, team=team)
response = client.delete(
f"{settings.API_V1_STR}/teams/{team.id}/members/{member.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Team member link deleted successfully"
# Verify deletion
response = client.get(
f"{settings.API_V1_STR}/teams/{team.id}/members/{member.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -0,0 +1,217 @@
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.place import create_random_place
from app.models.place import PlaceType, VisitedCountType
def test_create_place(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
data = {
"place_type": PlaceType.PLACE,
"name": "Post 1",
"short_name": "1",
"contact": "Sebas",
"description": "Post met renspel",
"question_file": None,
"answer_file": None,
"place_time": "PT10M",
"latitude": None,
"longitude": None,
"radius": None,
"max_points": None,
"visited_points": 10,
"visited_count_type": VisitedCountType.ONE_VISIT,
"skipped_penalty_points": 50,
}
response = client.post(
f"{settings.API_V1_STR}/places/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["name"] == data["name"]
assert content["place_type"] == data["place_type"]
assert content["name"] == data["name"]
assert content["short_name"] == data["short_name"]
assert content["contact"] == data["contact"]
assert content["description"] == data["description"]
assert content["question_file"] == data["question_file"]
assert content["answer_file"] == data["answer_file"]
assert content["latitude"] == data["latitude"]
assert content["longitude"] == data["longitude"]
assert content["radius"] == data["radius"]
assert content["max_points"] == data["max_points"]
assert content["visited_points"] == data["visited_points"]
assert content["visited_count_type"] == data["visited_count_type"]
assert content["skipped_penalty_points"] == data["skipped_penalty_points"]
assert content["place_time"] == data["place_time"]
assert "id" in content
def test_create_place_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
data = {
"place_type": PlaceType.PLACE,
"name": "No permissions",
"short_name": "No perm",
"visited_count_type": VisitedCountType.ONE_VISIT,
}
response = client.post(
f"{settings.API_V1_STR}/places/",
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_place(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
place = create_random_place(db)
response = client.get(
f"{settings.API_V1_STR}/places/{place.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(place.id)
assert content["name"] == place.name
assert content["contact"] == place.contact
def test_read_place_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/places/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Place not found"
def test_read_place_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
place = create_random_place(db)
response = client.get(
f"{settings.API_V1_STR}/places/{place.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_places(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
create_random_place(db)
create_random_place(db)
response = client.get(
f"{settings.API_V1_STR}/places/",
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_places_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
create_random_place(db)
create_random_place(db)
response = client.get(
f"{settings.API_V1_STR}/places/",
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_place(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
place = create_random_place(db)
data = {
"name": "Updated name",
"short_name": "4", # TODO: Fix Shortname
}
response = client.put(
f"{settings.API_V1_STR}/places/{place.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(place.id)
assert content["name"] == data["name"]
assert content["short_name"] == data["short_name"]
def test_update_place_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
data = {
"name": "Not found",
"short_name": "5", # TODO: Fix Shortname
}
response = client.put(
f"{settings.API_V1_STR}/places/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Place not found"
def test_update_place_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
place = create_random_place(db)
data = {
"name": "No permissions",
"short_name": "6", # TODO: Fix Shortname
}
response = client.put(
f"{settings.API_V1_STR}/places/{place.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_place(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
place = create_random_place(db)
response = client.delete(
f"{settings.API_V1_STR}/places/{place.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Place deleted successfully"
def test_delete_place_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
response = client.delete(
f"{settings.API_V1_STR}/places/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Place not found"
def test_delete_place_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
place = create_random_place(db)
response = client.delete(
f"{settings.API_V1_STR}/places/{place.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"

View File

@@ -4,26 +4,25 @@ from sqlmodel import Session, select
from app.core.config import settings
from app.models.user import User
from app.tests.utils.utils import random_email
def test_create_user(client: TestClient, db: Session) -> None:
data = {
"email": random_email(),
"password": "password123",
}
r = client.post(
f"{settings.API_V1_STR}/private/users/",
json={
"email": "pollo@listo.com",
"password": "password123",
"full_name": "Pollo Listo",
},
json=data,
)
assert r.status_code == status.HTTP_200_OK
data = r.json()
# TODO: Give user role
user = db.exec(select(User).where(User.id == data["id"])).first()
assert user
assert user.email == "pollo@listo.com"
assert user.full_name == "Pollo Listo"
assert user.email == data["email"]

View File

@@ -0,0 +1,187 @@
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.hike import create_random_hike
from app.tests.utils.route import create_random_route
def test_create_route(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
hike = create_random_hike(db)
data = {
"name": "Roete A",
"contact": "Sebas",
"hike_id": str(hike.id),
}
response = client.post(
f"{settings.API_V1_STR}/routes/",
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["hike_id"] == data["hike_id"]
assert "id" in content
def test_create_route_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
data = {
"name": "No permissions",
"contact": "No permissions",
"hike_id": str(uuid.uuid4()),
}
response = client.post(
f"{settings.API_V1_STR}/routes/",
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_route(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
route = create_random_route(db)
response = client.get(
f"{settings.API_V1_STR}/routes/{route.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(route.id)
assert content["name"] == route.name
assert content["contact"] == route.contact
def test_read_route_not_found(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
response = client.get(
f"{settings.API_V1_STR}/routes/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Route not found"
def test_read_route_no_permission(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
route = create_random_route(db)
response = client.get(
f"{settings.API_V1_STR}/routes/{route.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"
def test_read_routes(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
create_random_route(db)
create_random_route(db)
response = client.get(
f"{settings.API_V1_STR}/routes/",
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_routes_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
create_random_route(db)
create_random_route(db)
response = client.get(
f"{settings.API_V1_STR}/routes/",
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_route(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
route = create_random_route(db)
data = {
"name": "Updated name",
"contact": "Updated contact",
}
response = client.put(
f"{settings.API_V1_STR}/routes/{route.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["id"] == str(route.id)
assert content["name"] == data["name"]
assert content["contact"] == data["contact"]
def test_update_route_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}/routes/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Route not found"
def test_update_route_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
route = create_random_route(db)
data = {
"name": "No permissions",
"contact": "No permissions",
}
response = client.put(
f"{settings.API_V1_STR}/routes/{route.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_route(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
route = create_random_route(db)
response = client.delete(
f"{settings.API_V1_STR}/routes/{route.id}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["message"] == "Route deleted successfully"
def test_delete_route_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
response = client.delete(
f"{settings.API_V1_STR}/routes/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json()["detail"] == "Route not found"
def test_delete_route_no_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
route = create_random_route(db)
response = client.delete(
f"{settings.API_V1_STR}/routes/{route.id}",
headers=normal_user_token_headers,
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json()["detail"] == "Not enough permissions"

View File

@@ -17,6 +17,7 @@ def test_create_team(client: TestClient, superuser_token_headers: dict[str, str]
event = create_random_event(db)
data = {
"theme_name": "Foo",
"short_name": "1",
"event_id": str(event.id),
}
response = client.post(
@@ -27,12 +28,14 @@ def test_create_team(client: TestClient, superuser_token_headers: dict[str, str]
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["theme_name"] == data["theme_name"]
assert content["short_name"] == data["short_name"]
assert content["event_id"] == str(event.id)
assert "id" in content
def test_create_team_without_event(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
data = {
"theme_name": "No Event Team",
"short_name": "2",
}
response = client.post(
f"{settings.API_V1_STR}/teams/",
@@ -46,6 +49,7 @@ def test_create_team_without_event(client: TestClient, superuser_token_headers:
def test_create_team_with_incorrect_event(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
data = {
"theme_name": "No Event Team",
"short_name": "3",
"event_id": str(uuid.uuid4()), # Non-existent event
}
response = client.post(
@@ -63,6 +67,7 @@ def test_create_team_with_normal_user(
event = create_random_event(db)
data = {
"theme_name": "Normal user",
"short_name": "4",
"event_id": str(event.id),
}
response = client.post(
@@ -80,6 +85,7 @@ def test_create_team_with_event_user(
event = event_user_token_headers.event
data = {
"theme_name": "Event user",
"short_name": "5",
"event_id": str(event.id),
}
response = client.post(
@@ -90,6 +96,7 @@ def test_create_team_with_event_user(
assert response.status_code == status.HTTP_200_OK
content = response.json()
assert content["theme_name"] == data["theme_name"]
assert content["short_name"] == data["short_name"]
assert content["event_id"] == str(event.id)
@@ -99,6 +106,7 @@ def test_create_team_for_event_user(
event = create_random_event(db)
data = {
"theme_name": "Other event user",
"short_name": "6",
"event_id": str(event.id),
}
response = client.post(
@@ -120,6 +128,7 @@ def test_read_team(client: TestClient, superuser_token_headers: dict[str, str],
content = response.json()
assert content["id"] == str(team.id)
assert content["theme_name"] == team.theme_name
assert content["short_name"] == team.short_name
assert content["event_id"] == str(team.event_id)
def test_read_team_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
@@ -153,6 +162,7 @@ def test_read_team_with_event_user(client: TestClient, event_user_token_headers:
content = response.json()
assert content["id"] == str(team.id)
assert content["theme_name"] == team.theme_name
assert content["short_name"] == team.short_name
assert content["event_id"] == str(event_user_token_headers.event.id)
@@ -230,7 +240,10 @@ def test_read_teams_with_event_user_team_manager(client: TestClient, db: Session
def test_update_team_name(client: TestClient, superuser_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
data = {"theme_name": "Updated Team Name"}
data = {
"theme_name": "Updated Team Name",
"short_name": "7",
}
response = client.put(
f"{settings.API_V1_STR}/teams/{team.id}",
headers=superuser_token_headers,
@@ -240,11 +253,15 @@ def test_update_team_name(client: TestClient, superuser_token_headers: dict[str,
content = response.json()
assert content["id"] == str(team.id)
assert content["theme_name"] == data["theme_name"]
assert content["short_name"] == data["short_name"]
assert content["event_id"] == str(team.event_id)
def test_update_team_not_found(client: TestClient, superuser_token_headers: dict[str, str]) -> None:
data = {"theme_name": "Non-existent team"}
data = {
"theme_name": "Non-existent team",
"short_name": "8",
}
response = client.put(
f"{settings.API_V1_STR}/teams/{uuid.uuid4()}",
headers=superuser_token_headers,
@@ -256,7 +273,10 @@ def test_update_team_not_found(client: TestClient, superuser_token_headers: dict
def test_update_team_not_enough_permissions(client: TestClient, normal_user_token_headers: dict[str, str], db: Session) -> None:
team = create_random_team(db)
data = {"theme_name": "Not enough permissions team"}
data = {
"theme_name": "Not enough permissions team",
"short_name": "9",
}
response = client.put(
f"{settings.API_V1_STR}/teams/{team.id}",
headers=normal_user_token_headers,
@@ -268,7 +288,10 @@ def test_update_team_not_enough_permissions(client: TestClient, normal_user_toke
def test_update_team_name_with_event_permissions(client: TestClient, event_user_token_headers: EventUserHeader, db: Session) -> None:
team = create_random_team(db, event=event_user_token_headers.event)
data = {"theme_name": "Updated Team Name with Event permissions"}
data = {
"theme_name": "Updated Team Name with Event permissions",
"short_name": "10",
}
response = client.put(
f"{settings.API_V1_STR}/teams/{team.id}",
headers=event_user_token_headers.headers,
@@ -278,6 +301,7 @@ def test_update_team_name_with_event_permissions(client: TestClient, event_user_
content = response.json()
assert content["id"] == str(team.id)
assert content["theme_name"] == data["theme_name"]
assert content["short_name"] == data["short_name"]
assert content["event_id"] == str(event_user_token_headers.event.id)
@@ -328,6 +352,7 @@ def test_update_team_event_with_event_user(client: TestClient, event_user_token_
content = response.json()
assert content["id"] == str(team.id)
assert content["theme_name"] == team.theme_name
assert content["short_name"] == team.short_name
assert content["event_id"] == str(new_event.id)

View File

@@ -172,9 +172,8 @@ def test_retrieve_users(
def test_update_user_me(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
full_name = "Updated Name"
email = random_email()
data = {"full_name": full_name, "email": email}
data = {"email": email}
r = client.patch(
f"{settings.API_V1_STR}/users/me",
headers=normal_user_token_headers,
@@ -183,13 +182,11 @@ def test_update_user_me(
assert r.status_code == status.HTTP_200_OK
updated_user = r.json()
assert updated_user["email"] == email
assert updated_user["full_name"] == full_name
user_query = select(User).where(User.email == email)
user_db = db.exec(user_query).first()
assert user_db
assert user_db.email == email
assert user_db.full_name == full_name
def test_update_password_me(
@@ -311,8 +308,7 @@ def test_update_password_me_same_password_error(
def test_register_user(client: TestClient, db: Session) -> None:
username = random_email()
password = random_lower_string()
full_name = random_lower_string()
data = {"email": username, "password": password, "full_name": full_name}
data = {"email": username, "password": password}
r = client.post(
f"{settings.API_V1_STR}/users/signup",
json=data,
@@ -320,23 +316,19 @@ def test_register_user(client: TestClient, db: Session) -> None:
assert r.status_code == status.HTTP_200_OK
created_user = r.json()
assert created_user["email"] == username
assert created_user["full_name"] == full_name
user_query = select(User).where(User.email == username)
user_db = db.exec(user_query).first()
assert user_db
assert user_db.email == username
assert user_db.full_name == full_name
assert verify_password(password, user_db.hashed_password)
def test_register_user_already_exists_error(client: TestClient) -> None:
password = random_lower_string()
full_name = random_lower_string()
data = {
"email": settings.FIRST_SUPERUSER,
"password": password,
"full_name": full_name,
}
r = client.post(
f"{settings.API_V1_STR}/users/signup",
@@ -346,45 +338,6 @@ def test_register_user_already_exists_error(client: TestClient) -> None:
assert r.json()["detail"] == "The user with this email already exists in the system"
def test_update_user(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
user = User.create(session=db, create_obj=user_in)
data = {"full_name": "Updated_full_name"}
r = client.patch(
f"{settings.API_V1_STR}/users/{user.id}",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == status.HTTP_200_OK
updated_user = r.json()
assert updated_user["full_name"] == "Updated_full_name"
user_query = select(User).where(User.email == username)
user_db = db.exec(user_query).first()
db.refresh(user_db)
assert user_db
assert user_db.full_name == "Updated_full_name"
def test_update_user_not_exists(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"full_name": "Updated_full_name"}
r = client.patch(
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert r.status_code == status.HTTP_404_NOT_FOUND
assert r.json()["detail"] == "The user with this id does not exist in the system"
def test_update_user_email_exists(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:

View File

@@ -19,8 +19,8 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
statement = delete(User)
session.execute(statement)
# statement = delete(User)
# session.execute(statement)
session.commit()

View File

@@ -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)

View File

@@ -0,0 +1,17 @@
from sqlmodel import Session
from app.models.association import Association
from app.models.division import Division, DivisionCreate
from app.tests.utils.association import create_random_association
from app.tests.utils.utils import random_lower_string
def create_random_division(db: Session, name: str = None, association: Association = None) -> Division:
if not name:
name = random_lower_string()
if not association:
association = create_random_association(db)
division_in = DivisionCreate(name=name, association_id=association.id)
return Division.create(session=db, create_obj=division_in)

View File

@@ -0,0 +1,12 @@
from sqlmodel import Session
from app.models.hike import Hike, HikeCreate
from app.tests.utils.utils import random_lower_string
def create_random_hike(db: Session, name: str = None) -> Hike:
if not name:
name = random_lower_string()
hike_in = HikeCreate(name=name)
return Hike.create(session=db, create_obj=hike_in)

View File

@@ -0,0 +1,19 @@
from sqlmodel import Session
from app.models.team import Team
from app.models.member import Member, MemberCreate, MemberTeamLink, MemberTeamLinkCreate, MemberRank
from app.tests.utils.utils import random_lower_string
def create_random_member(db: Session) -> Member:
member_in = MemberCreate(
name=random_lower_string(),
contact=random_lower_string(),
scouting_id=random_lower_string(),
comment=random_lower_string(),
allergy=random_lower_string(),
# birthday_at=random_datetime(),
# canceled_at=random_datetime(),
canceled_reason=random_lower_string(),
)
return Member.create(session=db, create_obj=member_in)

View File

@@ -0,0 +1,14 @@
from sqlmodel import Session
from app.models.place import Place, PlaceCreate
from app.tests.utils.utils import random_lower_string, random_lower_short_string
def create_random_place(db: Session, name: str = None) -> Place:
if not name:
name = random_lower_string()
short_name = random_lower_short_string()
place_in = PlaceCreate(name=name, short_name=short_name)
return Place.create(session=db, create_obj=place_in)

View File

@@ -0,0 +1,17 @@
from sqlmodel import Session
from app.models.hike import Hike
from app.models.route import Route, RouteCreate
from app.tests.utils.utils import random_lower_string
from app.tests.utils.hike import create_random_hike
def create_random_route(db: Session, name: str = None, hike: Hike = None) -> Route:
if not name:
name = random_lower_string()
if not hike:
hike = create_random_hike(db=db)
route_in = RouteCreate(name=name, hike_id=hike.id)
return Route.create(session=db, create_obj=route_in)

View File

@@ -1,18 +1,21 @@
import random
from sqlmodel import Session
from app.models.event import Event
from app.models.team import Team, TeamCreate
from app.tests.utils.event import create_random_event
from app.tests.utils.utils import random_lower_string
from app.tests.utils.utils import random_lower_string, random_lower_short_string
def create_random_team(db: Session, event: Event | None = None) -> Team:
name = random_lower_string()
short_name = random_lower_short_string()
if not event:
event = create_random_event(db)
team_in = TeamCreate(theme_name=name, event_id=event.id)
team_in = TeamCreate(theme_name=name, short_name=short_name, event_id=event.id)
team = Team.create(session=db, create_obj=team_in)
return team

View File

@@ -10,6 +10,10 @@ def random_lower_string() -> str:
return "".join(random.choices(string.ascii_lowercase, k=32))
def random_lower_short_string() -> str:
return str(random.Random().randrange(1, 8))
def random_email() -> str:
return f"{random_lower_string()}@{random_lower_string()}.com"
@@ -24,3 +28,4 @@ def get_superuser_token_headers(client: TestClient) -> dict[str, str]:
a_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {a_token}"}
return headers