diff --git a/app/database/score.py b/app/database/score.py index 6b418bb..ec790a2 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -15,7 +15,12 @@ from app.calculator import ( ) from app.config import settings from app.database.team import TeamMember -from app.models.model import RespWithCursor, UTCBaseModel +from app.models.model import ( + CurrentUserAttributes, + PinAttributes, + RespWithCursor, + UTCBaseModel, +) from app.models.mods import APIMod, mods_can_get_pp from app.models.score import ( INT_TO_MODE, @@ -122,6 +127,7 @@ class Score(ScoreBase, table=True): nslider_tail_hit: int | None = Field(default=None, exclude=True) nsmall_tick_hit: int | None = Field(default=None, exclude=True) gamemode: GameMode = Field(index=True) + pinned_order: int = Field(default=0, exclude=True) # optional beatmap: Beatmap = Relationship() @@ -166,6 +172,7 @@ class ScoreResp(ScoreBase): rank_country: int | None = None position: int | None = None scores_around: "ScoreAround | None" = None + current_user_attributes: CurrentUserAttributes | None = None @classmethod async def from_db(cls, session: AsyncSession, score: Score) -> "ScoreResp": @@ -234,6 +241,9 @@ class ScoreResp(ScoreBase): ) or None ) + s.current_user_attributes = CurrentUserAttributes( + pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id) + ) return s diff --git a/app/models/model.py b/app/models/model.py index 5ba8093..34d4902 100644 --- a/app/models/model.py +++ b/app/models/model.py @@ -2,6 +2,8 @@ from __future__ import annotations from datetime import UTC, datetime +from app.models.score import GameMode + from pydantic import BaseModel, field_serializer @@ -20,3 +22,34 @@ Cursor = dict[str, int] class RespWithCursor(BaseModel): cursor: Cursor | None = None + + +class PinAttributes(BaseModel): + is_pinned: bool + score_id: int + + +class CurrentUserAttributes(BaseModel): + can_beatmap_update_owner: bool | None = None + can_delete: bool | None = None + can_edit_metadata: bool | None = None + can_edit_tags: bool | None = None + can_hype: bool | None = None + can_hype_reason: str | None = None + can_love: bool | None = None + can_remove_from_loved: bool | None = None + is_watching: bool | None = None + new_hype_time: datetime | None = None + nomination_modes: list[GameMode] | None = None + remaining_hype: int | None = None + can_destroy: bool | None = None + can_reopen: bool | None = None + can_moderate_kudosu: bool | None = None + can_resolve: bool | None = None + vote_score: int | None = None + can_message: bool | None = None + can_message_error: str | None = None + last_read_id: int | None = None + can_new_comment: bool | None = None + can_new_comment_reason: str | None = None + pin: PinAttributes | None = None diff --git a/app/router/score.py b/app/router/score.py index 2ec6da4..dbd82b1 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -43,12 +43,12 @@ from app.models.score import ( from .api_router import router -from fastapi import Depends, Form, HTTPException, Query +from fastapi import Body, Depends, Form, HTTPException, Query from httpx import HTTPError from pydantic import BaseModel from redis.asyncio import Redis from sqlalchemy.orm import joinedload -from sqlmodel import col, select +from sqlmodel import col, func, select from sqlmodel.ext.asyncio.session import AsyncSession READ_SCORE_TIMEOUT = 10 @@ -548,3 +548,158 @@ async def get_user_playlist_score( room_id, playlist_id, score_record.score_id, session ) return resp + + +@router.put("/score-pins/{score}", status_code=204) +async def pin_score( + score: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where( + Score.id == score, + Score.user_id == current_user.id, + col(Score.passed).is_(True), + ) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order > 0: + return + + next_order = ( + ( + await db.exec( + select(func.max(Score.pinned_order)).where( + Score.user_id == current_user.id, + Score.gamemode == score_record.gamemode, + ) + ) + ).first() + or 0 + ) + 1 + score_record.pinned_order = next_order + await db.commit() + + +@router.delete("/score-pins/{score}", status_code=204) +async def unpin_score( + score: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where(Score.id == score, Score.user_id == current_user.id) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order == 0: + return + changed_score = ( + await db.exec( + select(Score).where( + Score.user_id == current_user.id, + Score.pinned_order > score_record.pinned_order, + Score.gamemode == score_record.gamemode, + ) + ) + ).all() + for s in changed_score: + s.pinned_order -= 1 + await db.commit() + + +@router.post("/score-pins/{score}/reorder", status_code=204) +async def reorder_score_pin( + score: int, + after_score_id: int | None = Body(default=None), + before_score_id: int | None = Body(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + score_record = ( + await db.exec( + select(Score).where(Score.id == score, Score.user_id == current_user.id) + ) + ).first() + if not score_record: + raise HTTPException(status_code=404, detail="Score not found") + + if score_record.pinned_order == 0: + raise HTTPException(status_code=400, detail="Score is not pinned") + + if (after_score_id is None) == (before_score_id is None): + raise HTTPException( + status_code=400, + detail="Either after_score_id or before_score_id " + "must be provided (but not both)", + ) + + all_pinned_scores = ( + await db.exec( + select(Score) + .where( + Score.user_id == current_user.id, + Score.pinned_order > 0, + Score.gamemode == score_record.gamemode, + ) + .order_by(col(Score.pinned_order)) + ) + ).all() + + target_order = None + reference_score_id = after_score_id or before_score_id + + reference_score = next( + (s for s in all_pinned_scores if s.id == reference_score_id), None + ) + if not reference_score: + detail = "After score not found" if after_score_id else "Before score not found" + raise HTTPException(status_code=404, detail=detail) + + if after_score_id: + target_order = reference_score.pinned_order + 1 + else: + target_order = reference_score.pinned_order + + current_order = score_record.pinned_order + + if current_order == target_order: + return + + updates = [] + + if current_order < target_order: + for s in all_pinned_scores: + if current_order < s.pinned_order <= target_order and s.id != score: + updates.append((s.id, s.pinned_order - 1)) + if after_score_id: + final_target = ( + target_order - 1 if target_order > current_order else target_order + ) + else: + final_target = target_order + else: + for s in all_pinned_scores: + if target_order <= s.pinned_order < current_order and s.id != score: + updates.append((s.id, s.pinned_order + 1)) + final_target = target_order + + for score_id, new_order in updates: + await db.exec(select(Score).where(Score.id == score_id)) + score_to_update = ( + await db.exec(select(Score).where(Score.id == score_id)) + ).first() + if score_to_update: + score_to_update.pinned_order = new_order + + score_record.pinned_order = final_target + + await db.commit() diff --git a/app/router/user.py b/app/router/user.py index 089aa4f..c22fc85 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal + from app.database import ( BeatmapPlaycounts, BeatmapPlaycountsResp, @@ -8,6 +10,7 @@ from app.database import ( UserResp, ) from app.database.lazer_user import SEARCH_INCLUDED +from app.database.score import Score, ScoreResp from app.dependencies.database import get_db from app.dependencies.user import get_current_user from app.models.score import GameMode @@ -17,7 +20,7 @@ from .api_router import router from fastapi import Depends, HTTPException, Query from pydantic import BaseModel -from sqlmodel import select +from sqlmodel import false, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.sql.expression import col @@ -130,3 +133,52 @@ async def get_user_beatmapsets( raise HTTPException(400, detail="Invalid beatmapset type") return resp + + +@router.get("/users/{user}/scores/{type}", response_model=list[ScoreResp]) +async def get_user_scores( + user: int, + type: Literal["best", "recent", "firsts", "pinned"], + legacy_only: bool = Query(False), + include_fails: bool = Query(False), + mode: GameMode | None = None, + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_db), +): + db_user = await session.get(User, user) + if not db_user: + raise HTTPException(404, detail="User not found") + + gamemode = mode or db_user.playmode + order_by = None + where_clause = ( + (col(Score.user_id) == db_user.id) + & (col(Score.gamemode) == gamemode) + & (col(Score.passed).is_(True)) + ) + if type == "pinned": + where_clause &= Score.pinned_order > 0 + order_by = col(Score.pinned_order).asc() + else: + # TODO + where_clause &= false() + + scores = ( + await session.exec( + select(Score) + .where(where_clause) + .order_by(order_by) + .limit(limit) + .offset(offset) + ) + ).all() + if not scores: + return [] + return [ + await ScoreResp.from_db( + session, + score, + ) + for score in scores + ] diff --git a/migrations/versions/319e5f841dcf_score_support_pin_score.py b/migrations/versions/319e5f841dcf_score_support_pin_score.py new file mode 100644 index 0000000..ceacdec --- /dev/null +++ b/migrations/versions/319e5f841dcf_score_support_pin_score.py @@ -0,0 +1,34 @@ +"""score: support pin score + +Revision ID: 319e5f841dcf +Revises: 19cdc9ce4dcb +Create Date: 2025-08-10 14:07:51.749025 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "319e5f841dcf" +down_revision: str | Sequence[str] | None = "19cdc9ce4dcb" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("scores", sa.Column("pinned_order", sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("scores", "pinned_order") + # ### end Alembic commands ###