diff --git a/backend/actions/local_actions.py b/backend/actions/local_actions.py index 710e4e9d..1b34f38f 100644 --- a/backend/actions/local_actions.py +++ b/backend/actions/local_actions.py @@ -166,12 +166,12 @@ async def test_add_classification(local_dsrc): @pytest.mark.asyncio async def test_update_points(local_dsrc): async with get_conn(local_dsrc) as conn: - training_class = await data.classifications.most_recent_class_of_type( + training_class = (await data.classifications.most_recent_class_of_type( conn, "training" - ) - points_class = await data.classifications.most_recent_class_of_type( + ))[0] + points_class = (await data.classifications.most_recent_class_of_type( conn, "points" - ) + ))[0] await update_class_points(conn, training_class.classification_id) await update_class_points(conn, points_class.classification_id) diff --git a/backend/src/apiserver/app/ops/startup.py b/backend/src/apiserver/app/ops/startup.py index 791baf96..f32be385 100644 --- a/backend/src/apiserver/app/ops/startup.py +++ b/backend/src/apiserver/app/ops/startup.py @@ -1,4 +1,5 @@ from apiserver.app.error import AppError, ErrorKeys +from apiserver.data.special import update_class_points from loguru import logger from asyncio import sleep from datetime import date @@ -199,8 +200,11 @@ async def initial_population(dsrc: Source, config: Config) -> None: user_id = await data.user.insert_return_user_id(conn, fake_user) assert user_id == "1_fakerecord" - await insert_classification(conn, "training") - await insert_classification(conn, "points") + new_training_id = await insert_classification(conn, "training") + new_points_id = await insert_classification(conn, "points") + + await update_class_points(conn, new_training_id, False) + await update_class_points(conn, new_points_id, False) async def get_keystate(dsrc: Source) -> KeyState: diff --git a/backend/src/apiserver/app/routers/ranking.py b/backend/src/apiserver/app/routers/ranking.py index 3703d4da..8ff7a3d9 100644 --- a/backend/src/apiserver/app/routers/ranking.py +++ b/backend/src/apiserver/app/routers/ranking.py @@ -9,20 +9,30 @@ mod_user_events_in_class, ) from apiserver.app.response import RawJSONResponse -from apiserver.data.api.classifications import get_event_user_points +from apiserver.data.api.classifications import ( + get_event_user_points, + remove_classification, +) from apiserver.data import Source from apiserver.data.context.app_context import RankingContext, conn_wrap from apiserver.data.context.ranking import ( add_new_event, add_new_training, + context_modify_class, context_most_recent_class_points, + context_new_classes, + most_recent_classes, sync_publish_ranking, ) from apiserver.lib.logic.ranking import is_rank_type from apiserver.lib.model.entities import ( ClassEvent, + ClassMetaList, + ClassUpdate, + ClassView, NewEvent, NewTrainingEvent, + RankingInfo, UserEvent, UserPointsNames, UserPointsNamesList, @@ -75,10 +85,48 @@ async def get_classification( debug_key="bad_ranking", ) - user_points = await context_most_recent_class_points(ctx, dsrc, rank_type, admin) + user_points = ( + await context_most_recent_class_points(ctx, dsrc, rank_type, admin) + ).points return RawJSONResponse(UserPointsNamesList.dump_json(user_points)) +async def get_classification_with_info( + dsrc: Source, ctx: RankingContext, rank_type: str, admin: bool = False +) -> RawJSONResponse: + if not is_rank_type(rank_type): + reason = f"Ranking {rank_type} is unknown!" + raise ErrorResponse( + status_code=400, + err_type="invalid_ranking", + err_desc=reason, + debug_key="bad_ranking", + ) + + ranking_info = await context_most_recent_class_points(ctx, dsrc, rank_type, admin) + return RawJSONResponse(RankingInfo.model_dump_json(ranking_info).encode()) + + +@ranking_members_router.get("/get_with_info/{rank_type}/", response_model=RankingInfo) +async def member_classification_with_info( + rank_type: str, dsrc: SourceDep, app_context: AppContext +) -> RawJSONResponse: + return await get_classification_with_info( + dsrc, app_context.rank_ctx, rank_type, False + ) + + +@ranking_admin_router.get("/get_meta/{recent_number}/", response_model=list[ClassView]) +async def get_classifications( + recent_number: int, dsrc: SourceDep, app_context: AppContext +) -> RawJSONResponse: + recent_classes = await most_recent_classes( + app_context.rank_ctx, dsrc, recent_number + ) + + return RawJSONResponse(ClassMetaList.dump_json(recent_classes)) + + @ranking_members_router.get("/get/{rank_type}/", response_model=list[UserPointsNames]) async def member_classification( rank_type: str, dsrc: SourceDep, app_context: AppContext @@ -168,3 +216,31 @@ async def get_event_users( ) return RawJSONResponse(UserPointsNamesList.dump_json(event_users)) + + +@ranking_admin_router.post("/new/") +async def new_classes( + dsrc: SourceDep, + app_context: AppContext, +) -> None: + await context_new_classes(app_context.rank_ctx, dsrc) + + +@ranking_admin_router.post("/modify/") +async def modify_class( + updated_class: ClassUpdate, + dsrc: SourceDep, + app_context: AppContext, +) -> None: + await context_modify_class(app_context.rank_ctx, dsrc, updated_class) + + +@ranking_admin_router.post("/remove/{class_id}/") +async def remove_class( + class_id: int, + dsrc: SourceDep, + app_context: AppContext, +) -> None: + await ctxlize_wrap(remove_classification, conn_wrap)( + app_context.rank_ctx, dsrc, class_id + ) diff --git a/backend/src/apiserver/data/api/classifications.py b/backend/src/apiserver/data/api/classifications.py index e9907a30..120a6da9 100644 --- a/backend/src/apiserver/data/api/classifications.py +++ b/backend/src/apiserver/data/api/classifications.py @@ -1,13 +1,16 @@ from datetime import date, timedelta from typing import Literal +from schema.model.model import CLASS_END_DATE from sqlalchemy import RowMapping from sqlalchemy.ext.asyncio import AsyncConnection from apiserver.lib.model.entities import ( ClassEvent, + ClassMeta, + ClassMetaList, + ClassUpdate, Classification, - ClassView, EventDate, UserPoints, UserPointsNames, @@ -40,12 +43,15 @@ ) from store.db import ( LiteralDict, + delete_by_column, get_largest_where, insert, insert_many, + insert_return_col, lit_model, select_some_join_where, select_some_where, + update_by_unique, update_column_by_unique, ) from store.error import DataError, NoDataError, DbError, DbErrors @@ -61,7 +67,7 @@ def parse_user_points(user_points: list[RowMapping]) -> list[UserPointsNames]: async def insert_classification( conn: AsyncConnection, class_type: str, start_date: date | None = None -) -> None: +) -> int: if start_date is None: start_date = date.today() new_classification = Classification( @@ -71,12 +77,15 @@ async def insert_classification( end_date=start_date + timedelta(days=30 * 5), hidden_date=start_date + timedelta(days=30 * 4), ) - await insert(conn, CLASSIFICATION_TABLE, lit_model(new_classification)) + return_id: int = await insert_return_col( + conn, CLASSIFICATION_TABLE, lit_model(new_classification), CLASS_ID + ) + return return_id async def most_recent_class_of_type( - conn: AsyncConnection, class_type: Literal["training", "points"] -) -> ClassView: + conn: AsyncConnection, class_type: Literal["training", "points"], amount: int = 1 +) -> list[ClassMeta]: if class_type == "training": query_class_type = "training" elif class_type == "points": @@ -90,11 +99,18 @@ async def most_recent_class_of_type( largest_class_list = await get_largest_where( conn, CLASSIFICATION_TABLE, - {CLASS_ID, CLASS_LAST_UPDATED, CLASS_START_DATE, CLASS_HIDDEN_DATE}, + { + CLASS_ID, + CLASS_TYPE, + CLASS_LAST_UPDATED, + CLASS_START_DATE, + CLASS_HIDDEN_DATE, + CLASS_END_DATE, + }, CLASS_TYPE, query_class_type, CLASS_START_DATE, - 1, + amount, ) if len(largest_class_list) == 0: raise NoDataError( @@ -102,7 +118,7 @@ async def most_recent_class_of_type( "no_most_recent_training_class", ) - return ClassView.model_validate(largest_class_list[0]) + return ClassMetaList.validate_python(largest_class_list) async def all_points_in_class( @@ -260,3 +276,17 @@ async def class_update_last_updated( return await update_column_by_unique( conn, CLASSIFICATION_TABLE, CLASS_LAST_UPDATED, date, CLASS_ID, class_id ) + + +async def update_classification(conn: AsyncConnection, class_view: ClassUpdate) -> None: + await update_by_unique( + conn, + CLASSIFICATION_TABLE, + lit_model(class_view), + "classification_id", + class_view.classification_id, + ) + + +async def remove_classification(conn: AsyncConnection, class_id: int) -> None: + await delete_by_column(conn, CLASSIFICATION_TABLE, "classification_id", class_id) diff --git a/backend/src/apiserver/data/context/app_context.py b/backend/src/apiserver/data/context/app_context.py index aa99c7e0..de7005a1 100644 --- a/backend/src/apiserver/data/context/app_context.py +++ b/backend/src/apiserver/data/context/app_context.py @@ -18,7 +18,10 @@ from apiserver.data import Source from apiserver.lib.model.entities import ( ClassEvent, + ClassMeta, + ClassUpdate, NewEvent, + RankingInfo, UserData, User, UserEvent, @@ -76,7 +79,7 @@ async def context_most_recent_class_id_of_type( @classmethod async def context_most_recent_class_points( cls, dsrc: Source, rank_type: Literal["points", "training"], is_admin: bool - ) -> list[UserPointsNames]: + ) -> RankingInfo: raise ContextNotImpl() @classmethod @@ -101,6 +104,22 @@ async def context_get_event_users( ) -> list[UserPointsNames]: raise ContextNotImpl() + @classmethod + async def most_recent_classes( + cls, dsrc: Source, amount: int = 10 + ) -> list[ClassMeta]: + raise ContextNotImpl() + + @classmethod + async def context_new_classes(cls, dsrc: Source) -> None: + raise ContextNotImpl() + + @classmethod + async def context_modify_class( + cls, dsrc: Source, class_update: ClassUpdate + ) -> None: + raise ContextNotImpl() + class AuthorizeAppContext(Context): @classmethod diff --git a/backend/src/apiserver/data/context/ranking.py b/backend/src/apiserver/data/context/ranking.py index ec75dfc7..52f30a33 100644 --- a/backend/src/apiserver/data/context/ranking.py +++ b/backend/src/apiserver/data/context/ranking.py @@ -1,3 +1,4 @@ +from datetime import date from apiserver.data.api.trainings import add_training_event from datacontext.context import ContextRegistry from typing import Any, Literal @@ -8,9 +9,12 @@ from apiserver.lib.model.entities import ( ClassEvent, + ClassMeta, + ClassUpdate, ClassView, NewEvent, NewTrainingEvent, + RankingInfo, UserEvent, UserPointsNames, ) @@ -23,7 +27,9 @@ class_update_last_updated, events_in_class, get_event_user_points, + insert_classification, most_recent_class_of_type, + update_classification, ) from apiserver.data.context import RankingContext from apiserver.data.source import get_conn @@ -47,7 +53,9 @@ async def add_new_event(dsrc: Source, new_event: NewEvent) -> None: date. Use the 'publish' function to force them to be equal.""" async with get_conn(dsrc) as conn: try: - classification = await most_recent_class_of_type(conn, new_event.class_type) + classification = ( + await most_recent_class_of_type(conn, new_event.class_type) + )[0] except DataError as e: if e.key != "incorrect_class_type": raise e @@ -133,29 +141,38 @@ async def context_most_recent_class_id_of_type( dsrc: Source, rank_type: Literal["points", "training"] ) -> int: async with get_conn(dsrc) as conn: - class_id = (await most_recent_class_of_type(conn, rank_type)).classification_id + class_id = (await most_recent_class_of_type(conn, rank_type))[ + 0 + ].classification_id return class_id @ctx_reg.register(RankingContext) async def context_most_recent_class_points( - dsrc: Source, rank_type: Literal["points", "training"], is_admin: bool -) -> list[UserPointsNames]: + dsrc: Source, + rank_type: Literal["points", "training"], + is_admin: bool, +) -> RankingInfo: async with get_conn(dsrc) as conn: - class_view = await most_recent_class_of_type(conn, rank_type) + class_view = (await most_recent_class_of_type(conn, rank_type))[0] user_points = await all_points_in_class( conn, class_view.classification_id, is_admin ) - return user_points + is_frozen = date.today() >= class_view.hidden_date + ranking_info = RankingInfo( + points=user_points, last_updated=class_view.last_updated, frozen=is_frozen + ) + + return ranking_info @ctx_reg.register(RankingContext) async def sync_publish_ranking(dsrc: Source, publish: bool) -> None: async with get_conn(dsrc) as conn: - training_class = await most_recent_class_of_type(conn, "training") - points_class = await most_recent_class_of_type(conn, "points") + training_class = (await most_recent_class_of_type(conn, "training"))[0] + points_class = (await most_recent_class_of_type(conn, "points"))[0] await update_class_points(conn, training_class.classification_id, publish) await update_class_points(conn, points_class.classification_id, publish) @@ -185,3 +202,41 @@ async def context_get_event_users(dsrc: Source, event_id: str) -> list[UserPoint events_points = await get_event_user_points(conn, event_id) return events_points + + +MIN_AMOUNT = 2 + + +@ctx_reg.register(RankingContext) +async def most_recent_classes(dsrc: Source, amount: int = 10) -> list[ClassMeta]: + if amount < MIN_AMOUNT or amount % 2 != 0: + raise AppError( + ErrorKeys.DATA, + "Request at least 2 classes and make sure it is an even number!", + "most_recent_too_few", + ) + + async with get_conn(dsrc) as conn: + training_classes = await most_recent_class_of_type( + conn, "training", amount // 2 + ) + points_classes = await most_recent_class_of_type(conn, "points", amount // 2) + + return training_classes + points_classes + + +@ctx_reg.register(RankingContext) +async def context_new_classes(dsrc: Source) -> None: + async with get_conn(dsrc) as conn: + new_training_id = await insert_classification(conn, "training") + new_points_id = await insert_classification(conn, "points") + + await update_class_points(conn, new_training_id, False) + await update_class_points(conn, new_points_id, False) + + +@ctx_reg.register(RankingContext) +async def context_modify_class(dsrc: Source, class_update: ClassUpdate) -> None: + async with get_conn(dsrc) as conn: + await update_classification(conn, class_update) + await update_class_points(conn, class_update.classification_id, False) diff --git a/backend/src/apiserver/lib/model/entities.py b/backend/src/apiserver/lib/model/entities.py index fbedea1c..1f821428 100644 --- a/backend/src/apiserver/lib/model/entities.py +++ b/backend/src/apiserver/lib/model/entities.py @@ -223,6 +223,12 @@ class UserPointsNames(BaseModel): UserPointsNamesList = TypeAdapter(List[UserPointsNames]) +class RankingInfo(BaseModel): + last_updated: date + frozen: bool + points: list[UserPointsNames] + + # class PointsData(BaseModel): # points: int @@ -276,3 +282,18 @@ class NewTrainingEvent(BaseModel): class EventDate(BaseModel): date: date + + +class ClassMeta(ClassView): + type: Literal["points", "training"] + end_date: date + + +class ClassUpdate(BaseModel): + classification_id: int + start_date: date + hidden_date: date + end_date: date + + +ClassMetaList = TypeAdapter(List[ClassMeta]) diff --git a/backend/src/store/db.py b/backend/src/store/db.py index 055730bc..b4ad53d8 100644 --- a/backend/src/store/db.py +++ b/backend/src/store/db.py @@ -227,6 +227,25 @@ async def upsert_by_unique( return row_cnt(res) +async def update_by_unique( + conn: AsyncConnection, + table: LiteralString, + set_dict: LiteralDict, + unique_column: LiteralString, + value: Any, +) -> int: + """Note that while the values are safe from injection, the column names are not.""" + + _, _, row_keys_set = _row_keys_vars_set(set_dict) + + query = text(f"UPDATE {table} SET {row_keys_set} WHERE {unique_column} = :val;") + val_dict: LiteralDict = {"val": value} + params = set_dict | val_dict + + res = await execute_catch(conn, query, parameters=params) + return row_cnt(res) + + async def update_column_by_unique( conn: AsyncConnection, table: LiteralString, diff --git a/dev.nu b/dev.nu index eb9c9f84..26272273 100755 --- a/dev.nu +++ b/dev.nu @@ -10,7 +10,7 @@ def pull [envnmt: string, env_file: string, profile: string] { } def up [envnmt: string, env_file: string, profile: string] { - pull $envnmt $env_file $profile + # pull $envnmt $env_file $profile docker compose -f $"($deploy_dir)/use/($envnmt)/docker-compose.yml" --env-file $"($deploy_dir)/use/($envnmt)/($env_file).env" --profile $profile up -d } @@ -49,4 +49,4 @@ def "main backend" [] { # important for the command to be exposed to the outside # Useful development commands for starting and stopping databases -def main [] {} \ No newline at end of file +def main [] {}