from datetime import UTC, datetime from typing import TYPE_CHECKING, NotRequired, TypedDict from app.models.model import UTCBaseModel from app.models.score import GameMode from app.models.user import Country, Page, RankHistory from .achievement import UserAchievement, UserAchievementResp from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .statistics import UserStatistics, UserStatisticsResp from .team import Team, TeamMember from .user_account_history import UserAccountHistory, UserAccountHistoryResp from sqlalchemy.orm import joinedload, selectinload from sqlmodel import ( JSON, BigInteger, Column, DateTime, Field, Relationship, SQLModel, func, select, ) from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from app.database.relationship import RelationshipResp class Kudosu(TypedDict): available: int total: int class RankHighest(TypedDict): rank: int updated_at: datetime class UserProfileCover(TypedDict): url: str custom_url: NotRequired[str] id: NotRequired[str] Badge = TypedDict( "Badge", { "awarded_at": datetime, "description": str, "image@2x_url": str, "image_url": str, "url": str, }, ) class UserBase(UTCBaseModel, SQLModel): avatar_url: str = "" country_code: str = Field(default="CN", max_length=2, index=True) # ? default_group: str|None is_active: bool = True is_bot: bool = False is_supporter: bool = False last_visit: datetime = Field( default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True)) ) pm_friends_only: bool = False profile_colour: str | None = None username: str = Field(max_length=32, unique=True, index=True) page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw="")) previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON)) # TODO: replays_watched_counts support_level: int = 0 badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON)) # optional is_restricted: bool = False # blocks cover: UserProfileCover = Field( default=UserProfileCover( url="https://assets.ppy.sh/user-profile-covers/default.jpeg" ), sa_column=Column(JSON), ) beatmap_playcounts_count: int = 0 # kudosu # UserExtended playmode: GameMode = GameMode.OSU discord: str | None = None has_supported: bool = False interests: str | None = None join_date: datetime = Field(default=datetime.now(UTC)) location: str | None = None max_blocks: int = 50 max_friends: int = 500 occupation: str | None = None playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON)) # TODO: post_count profile_hue: int | None = None profile_order: list[str] = Field( default_factory=lambda: [ "me", "recent_activity", "top_ranks", "medals", "historical", "beatmaps", "kudosu", ], sa_column=Column(JSON), ) title: str | None = None title_url: str | None = None twitter: str | None = None website: str | None = None # undocumented comments_count: int = 0 post_count: int = 0 is_admin: bool = False is_gmt: bool = False is_qat: bool = False is_bng: bool = False class User(UserBase, table=True): __tablename__ = "lazer_users" # pyright: ignore[reportAssignmentType] id: int | None = Field( default=None, sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True), ) account_history: list[UserAccountHistory] = Relationship() statistics: list[UserStatistics] = Relationship() achievement: list[UserAchievement] = Relationship(back_populates="user") team_membership: TeamMember | None = Relationship(back_populates="user") daily_challenge_stats: DailyChallengeStats | None = Relationship( back_populates="user" ) email: str = Field(max_length=254, unique=True, index=True, exclude=True) priv: int = Field(default=1, exclude=True) pw_bcrypt: str = Field(max_length=60, exclude=True) silence_end_at: datetime | None = Field( default=None, sa_column=Column(DateTime(timezone=True)), exclude=True ) donor_end_at: datetime | None = Field( default=None, sa_column=Column(DateTime(timezone=True)), exclude=True ) @classmethod def all_select_option(cls): return ( selectinload(cls.account_history), # pyright: ignore[reportArgumentType] selectinload(cls.statistics), # pyright: ignore[reportArgumentType] selectinload(cls.achievement), # pyright: ignore[reportArgumentType] joinedload(cls.team_membership).joinedload(TeamMember.team), # pyright: ignore[reportArgumentType] joinedload(cls.daily_challenge_stats), # pyright: ignore[reportArgumentType] ) class UserResp(UserBase): id: int | None = None is_online: bool = True # TODO groups: list = [] # TODO country: Country = Field(default_factory=lambda: Country(code="CN", name="China")) favourite_beatmapset_count: int = 0 # TODO graveyard_beatmapset_count: int = 0 # TODO guest_beatmapset_count: int = 0 # TODO loved_beatmapset_count: int = 0 # TODO mapping_follower_count: int = 0 # TODO nominated_beatmapset_count: int = 0 # TODO pending_beatmapset_count: int = 0 # TODO ranked_beatmapset_count: int = 0 # TODO follow_user_mapping: list[int] = Field(default_factory=list) follower_count: int = 0 friends: list["RelationshipResp"] | None = None scores_best_count: int = 0 scores_first_count: int = 0 scores_recent_count: int = 0 scores_pinned_count: int = 0 account_history: list[UserAccountHistoryResp] = [] active_tournament_banners: list[dict] = [] # TODO kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO monthly_playcounts: list = Field(default_factory=list) # TODO unread_pm_count: int = 0 # TODO rank_history: RankHistory | None = None # TODO rank_highest: RankHighest | None = None # TODO statistics: UserStatisticsResp | None = None statistics_rulesets: dict[str, UserStatisticsResp] | None = None user_achievements: list[UserAchievementResp] = Field(default_factory=list) cover_url: str = "" # deprecated team: Team | None = None session_verified: bool = True daily_challenge_user_stats: DailyChallengeStatsResp | None = None # TODO # TODO: monthly_playcounts, unread_pm_count, rank_history, user_preferences @classmethod async def from_db( cls, obj: User, session: AsyncSession, include: list[str] = [], ruleset: GameMode | None = None, ) -> "UserResp": from .best_score import BestScore from .relationship import Relationship, RelationshipResp, RelationshipType u = cls.model_validate(obj.model_dump()) u.id = obj.id u.follower_count = ( await session.exec( select(func.count()) .select_from(Relationship) .where( Relationship.target_id == obj.id, Relationship.type == RelationshipType.FOLLOW, ) ) ).one() u.scores_best_count = ( await session.exec( select(func.count()) .select_from(BestScore) .where( BestScore.user_id == obj.id, ) .limit(200) ) ).one() u.cover_url = ( obj.cover.get( "url", "https://assets.ppy.sh/user-profile-covers/default.jpeg" ) if obj.cover else "https://assets.ppy.sh/user-profile-covers/default.jpeg" ) if "friends" in include: u.friends = [ await RelationshipResp.from_db(session, r) for r in ( await session.exec( select(Relationship) .options( joinedload(Relationship.target).options( # pyright: ignore[reportArgumentType] *User.all_select_option() ) ) .where( Relationship.user_id == obj.id, Relationship.type == RelationshipType.FOLLOW, ) ) ).all() ] if "team" in include: if obj.team_membership: u.team = obj.team_membership.team if "account_history" in include: u.account_history = [ UserAccountHistoryResp.from_db(ah) for ah in obj.account_history ] if "daily_challenge_user_stats": if obj.daily_challenge_stats: u.daily_challenge_user_stats = DailyChallengeStatsResp.from_db( obj.daily_challenge_stats ) if "statistics" in include: current_stattistics = None for i in obj.statistics: if i.mode == (ruleset or obj.playmode): current_stattistics = i break u.statistics = ( UserStatisticsResp.from_db(current_stattistics) if current_stattistics else None ) if "statistics_rulesets" in include: u.statistics_rulesets = { i.mode.value: UserStatisticsResp.from_db(i) for i in obj.statistics } if "achievements" in include: u.user_achievements = [ UserAchievementResp.from_db(ua) for ua in obj.achievement ] return u