feat(team): add playmode, description, website and statistics

This commit is contained in:
MingxuanGame
2025-10-08 05:46:17 +00:00
parent e2f3c5099f
commit 45ed9e51a9
4 changed files with 176 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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