From 45ed9e51a95f68406ed8943f405aa7d0a9cca5f3 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Wed, 8 Oct 2025 05:46:17 +0000 Subject: [PATCH] feat(team): add playmode, description, website and statistics --- app/database/__init__.py | 3 +- app/database/team.py | 104 +++++++++++++++++- app/router/private/team.py | 36 +++++- ...16de_team_add_description_playmode_and_.py | 43 ++++++++ 4 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 migrations/versions/2025-10-08_48fb754416de_team_add_description_playmode_and_.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 11a60ee..da18f70 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -55,7 +55,7 @@ from .statistics import ( UserStatistics, UserStatisticsResp, ) -from .team import Team, TeamMember, TeamRequest +from .team import Team, TeamMember, TeamRequest, TeamResp from .total_score_best_scores import TotalScoreBestScore from .user import ( MeResp, @@ -131,6 +131,7 @@ __all__ = [ "Team", "TeamMember", "TeamRequest", + "TeamResp", "TotalScoreBestScore", "TotpKeys", "TrustedDevice", diff --git a/app/database/team.py b/app/database/team.py index 7788f99..ebbe2c0 100644 --- a/app/database/team.py +++ b/app/database/team.py @@ -2,18 +2,18 @@ from datetime import datetime from typing import TYPE_CHECKING from app.models.model import UTCBaseModel +from app.models.score import GameMode from app.utils import utcnow from sqlalchemy import Column, DateTime -from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel +from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel, Text, col, func, select +from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from .user import User -class Team(SQLModel, UTCBaseModel, table=True): - __tablename__: str = "teams" - +class TeamBase(SQLModel, UTCBaseModel): id: int = Field(default=None, primary_key=True, index=True) name: str = Field(max_length=100) short_name: str = Field(max_length=10) @@ -21,11 +21,107 @@ class Team(SQLModel, UTCBaseModel, table=True): cover_url: str | None = Field(default=None) created_at: datetime = Field(default_factory=utcnow, sa_column=Column(DateTime)) leader_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id"))) + description: str | None = Field(default=None, sa_column=Column(Text)) + playmode: GameMode = Field(default=GameMode.OSU) + website: str | None = Field(default=None, sa_column=Column(Text)) + + +class Team(TeamBase, table=True): + __tablename__: str = "teams" leader: "User" = Relationship() members: list["TeamMember"] = Relationship(back_populates="team") +class TeamResp(TeamBase): + rank: int = 0 + pp: float = 0.0 + ranked_score: int = 0 + total_play_count: int = 0 + member_count: int = 0 + + @classmethod + async def from_db(cls, team: Team, session: AsyncSession, gamemode: GameMode | None = None) -> "TeamResp": + from .statistics import UserStatistics + from .user import User + + playmode = gamemode or team.playmode + + pp_expr = func.coalesce(func.sum(col(UserStatistics.pp)), 0.0) + ranked_score_expr = func.coalesce(func.sum(col(UserStatistics.ranked_score)), 0) + play_count_expr = func.coalesce(func.sum(col(UserStatistics.play_count)), 0) + member_count_expr = func.count(func.distinct(col(UserStatistics.user_id))) + + team_stats_stmt = ( + select(pp_expr, ranked_score_expr, play_count_expr, member_count_expr) + .select_from(UserStatistics) + .join(TeamMember, col(TeamMember.user_id) == col(UserStatistics.user_id)) + .join(User, col(User.id) == col(UserStatistics.user_id)) + .join(Team, col(Team.id) == col(TeamMember.team_id)) + .where( + col(Team.id) == team.id, + col(Team.playmode) == playmode, + col(UserStatistics.mode) == playmode, + col(UserStatistics.pp) > 0, + col(UserStatistics.is_ranked).is_(True), + ~User.is_restricted_query(col(UserStatistics.user_id)), + ) + ) + + team_stats_result = await session.exec(team_stats_stmt) + stats_row = team_stats_result.one_or_none() + if stats_row is None: + total_pp = 0.0 + total_ranked_score = 0 + total_play_count = 0 + active_member_count = 0 + else: + total_pp, total_ranked_score, total_play_count, active_member_count = stats_row + total_pp = float(total_pp or 0.0) + total_ranked_score = int(total_ranked_score or 0) + total_play_count = int(total_play_count or 0) + active_member_count = int(active_member_count or 0) + + total_pp_ranking_expr = func.coalesce(func.sum(col(UserStatistics.pp)), 0.0) + ranking_stmt = ( + select(Team.id, total_pp_ranking_expr) + .select_from(Team) + .join(TeamMember, col(TeamMember.team_id) == col(Team.id)) + .join(UserStatistics, col(UserStatistics.user_id) == col(TeamMember.user_id)) + .join(User, col(User.id) == col(TeamMember.user_id)) + .where( + col(Team.playmode) == playmode, + col(UserStatistics.mode) == playmode, + col(UserStatistics.pp) > 0, + col(UserStatistics.is_ranked).is_(True), + ~User.is_restricted_query(col(UserStatistics.user_id)), + ) + .group_by(col(Team.id)) + .order_by(total_pp_ranking_expr.desc()) + ) + + ranking_result = await session.exec(ranking_stmt) + ranking_rows = ranking_result.all() + rank = 0 + for index, (team_id, _) in enumerate(ranking_rows, start=1): + if team_id == team.id: + rank = index + break + + data = team.model_dump() + data.update( + { + "pp": total_pp, + "ranked_score": total_ranked_score, + "total_play_count": total_play_count, + "member_count": active_member_count, + "rank": rank, + } + ) + + return cls.model_validate(data) + + class TeamMember(SQLModel, UTCBaseModel, table=True): __tablename__: str = "team_members" diff --git a/app/router/private/team.py b/app/router/private/team.py index e618501..826176a 100644 --- a/app/router/private/team.py +++ b/app/router/private/team.py @@ -1,7 +1,7 @@ import hashlib from typing import Annotated -from app.database.team import Team, TeamMember, TeamRequest +from app.database.team import Team, TeamMember, TeamRequest, TeamResp from app.database.user import BASE_INCLUDES, User, UserResp from app.dependencies.database import Database, Redis from app.dependencies.storage import StorageService @@ -11,13 +11,14 @@ from app.models.notification import ( TeamApplicationReject, TeamApplicationStore, ) +from app.models.score import GameMode from app.router.notification import server from app.service.ranking_cache_service import get_ranking_cache_service from app.utils import check_image, utcnow from .router import router -from fastapi import File, Form, HTTPException, Path, Request +from fastapi import File, Form, HTTPException, Path, Query, Request from pydantic import BaseModel from sqlmodel import col, exists, select @@ -32,6 +33,9 @@ async def create_team( name: Annotated[str, Form(max_length=100, description="战队名称")], short_name: Annotated[str, Form(max_length=10, description="战队缩写")], redis: Redis, + playmode: Annotated[GameMode, Form(description="战队游戏模式")] = GameMode.OSU, + description: Annotated[str | None, Form(description="战队简介")] = None, + website: Annotated[str | None, Form(description="战队网址")] = None, ): """创建战队。 @@ -55,8 +59,19 @@ async def create_team( flag_format = check_image(flag, 2 * 1024 * 1024, 240, 120) cover_format = check_image(cover, 10 * 1024 * 1024, 3000, 2000) + if website and not (website.startswith("http://") or website.startswith("https://")): + website = "https://" + website + now = utcnow() - team = Team(name=name, short_name=short_name, leader_id=user_id, created_at=now) + team = Team( + name=name, + short_name=short_name, + leader_id=user_id, + created_at=now, + playmode=playmode, + description=description, + website=website, + ) session.add(team) await session.commit() await session.refresh(team) @@ -95,6 +110,9 @@ async def update_team( name: Annotated[str | None, Form(max_length=100, description="战队名称")] = None, short_name: Annotated[str | None, Form(max_length=10, description="战队缩写")] = None, leader_id: Annotated[int | None, Form(description="战队队长 ID")] = None, + playmode: Annotated[GameMode, Form(description="战队游戏模式")] = GameMode.OSU, + description: Annotated[str | None, Form(description="战队简介")] = None, + website: Annotated[str | None, Form(description="战队网址")] = None, ): """修改战队。 @@ -122,6 +140,13 @@ async def update_team( else: team.short_name = short_name + team.playmode = playmode or team.playmode + team.description = description + if website is not None: + if website and not (website.startswith("http://") or website.startswith("https://")): + website = "https://" + website + team.website = website + if flag: format_ = check_image(flag, 2 * 1024 * 1024, 240, 120) @@ -190,7 +215,7 @@ async def delete_team( class TeamQueryResp(BaseModel): - team: Team + team: TeamResp members: list[UserResp] @@ -198,6 +223,7 @@ class TeamQueryResp(BaseModel): async def get_team( session: Database, team_id: Annotated[int, Path(..., description="战队 ID")], + gamemode: Annotated[GameMode | None, Query(description="游戏模式")] = None, ): members = ( await session.exec( @@ -208,7 +234,7 @@ async def get_team( ) ).all() return TeamQueryResp( - team=members[0].team, + team=await TeamResp.from_db(members[0].team, session, gamemode), members=[await UserResp.from_db(m.user, session, include=BASE_INCLUDES) for m in members], ) diff --git a/migrations/versions/2025-10-08_48fb754416de_team_add_description_playmode_and_.py b/migrations/versions/2025-10-08_48fb754416de_team_add_description_playmode_and_.py new file mode 100644 index 0000000..63222a7 --- /dev/null +++ b/migrations/versions/2025-10-08_48fb754416de_team_add_description_playmode_and_.py @@ -0,0 +1,43 @@ +"""team: add description, playmode and website + +Revision ID: 48fb754416de +Revises: fa4952dc70df +Create Date: 2025-10-08 05:26:13.832105 + +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "48fb754416de" +down_revision: str | Sequence[str] | None = "fa4952dc70df" +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("teams", sa.Column("description", sa.Text(), nullable=True)) + op.add_column( + "teams", + sa.Column( + "playmode", + sa.Enum("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX", name="gamemode"), + nullable=False, + ), + ) + op.add_column("teams", sa.Column("website", sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("teams", "website") + op.drop_column("teams", "playmode") + op.drop_column("teams", "description") + # ### end Alembic commands ###