fix(recent_activity): fix rank & achievement event
运行 tools/fix_user_rank_event.py 修复现存的 event
This commit is contained in:
@@ -24,6 +24,7 @@ from .counts import (
|
||||
)
|
||||
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
|
||||
from .email_verification import EmailVerification, LoginSession
|
||||
from .events import Event
|
||||
from .favourite_beatmapset import FavouriteBeatmapset
|
||||
from .lazer_user import (
|
||||
User,
|
||||
@@ -83,6 +84,7 @@ __all__ = [
|
||||
"DailyChallengeStats",
|
||||
"DailyChallengeStatsResp",
|
||||
"EmailVerification",
|
||||
"Event",
|
||||
"FavouriteBeatmapset",
|
||||
"ItemAttemptsCount",
|
||||
"ItemAttemptsResp",
|
||||
|
||||
@@ -77,7 +77,7 @@ async def process_achievements(session: AsyncSession, redis: Redis, score_id: in
|
||||
type=EventType.ACHIEVEMENT,
|
||||
user_id=score.user_id,
|
||||
event_payload={
|
||||
"achievement": {"achievement_id": r.id, "achieved_at": now.isoformat()},
|
||||
"achievement": {"slug": r.assets_id, "name": r.name},
|
||||
"user": {
|
||||
"username": score.user.username,
|
||||
"url": settings.web_url + "users/" + str(score.user.id),
|
||||
|
||||
@@ -2,6 +2,9 @@ from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from pydantic import model_serializer
|
||||
from sqlmodel import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
@@ -34,26 +37,25 @@ class EventType(str, Enum):
|
||||
USERNAME_CHANGE = "username_change"
|
||||
|
||||
|
||||
class EventBase(SQLModel):
|
||||
class Event(UTCBaseModel, SQLModel, table=True):
|
||||
__tablename__: str = "user_events"
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), default=datetime.now(UTC)))
|
||||
type: EventType
|
||||
event_payload: dict = Field(exclude=True, default_factory=dict, sa_column=Column(JSON))
|
||||
|
||||
|
||||
class Event(EventBase, table=True):
|
||||
__tablename__: str = "user_events"
|
||||
user_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True),
|
||||
)
|
||||
user: "User" = Relationship(back_populates="events")
|
||||
|
||||
|
||||
class EventResp(EventBase):
|
||||
def merge_payload(self) -> "EventResp":
|
||||
for key, value in self.event_payload.items():
|
||||
setattr(self, key, value)
|
||||
return self
|
||||
|
||||
pass
|
||||
@model_serializer
|
||||
def serialize(self) -> dict:
|
||||
d = {
|
||||
"id": self.id,
|
||||
"createdAt": self.created_at.replace(tzinfo=UTC).isoformat(),
|
||||
"type": self.type.value,
|
||||
}
|
||||
for k, v in self.event_payload.items():
|
||||
d[k] = v
|
||||
return d
|
||||
|
||||
@@ -71,6 +71,18 @@ class GameMode(str, Enum):
|
||||
7: GameMode.FRUITSRX,
|
||||
}[v]
|
||||
|
||||
def readable(self) -> str:
|
||||
return {
|
||||
GameMode.OSU: "osu!",
|
||||
GameMode.TAIKO: "osu!taiko",
|
||||
GameMode.FRUITS: "osu!catch",
|
||||
GameMode.MANIA: "osu!mania",
|
||||
GameMode.OSURX: "osu!relax",
|
||||
GameMode.OSUAP: "osu!autopilot",
|
||||
GameMode.TAIKORX: "taiko relax",
|
||||
GameMode.FRUITSRX: "catch relax",
|
||||
}[self]
|
||||
|
||||
def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode":
|
||||
if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS):
|
||||
return self
|
||||
|
||||
@@ -172,10 +172,13 @@ async def submit_score(
|
||||
user=score.user,
|
||||
)
|
||||
rank_event.event_payload = {
|
||||
"scorerank": str(score.rank),
|
||||
"scorerank": score.rank.value,
|
||||
"rank": resp.rank_global,
|
||||
"mode": str(resp.beatmap.mode), # pyright: ignore[reportOptionalMemberAccess]
|
||||
"beatmap": {"title": resp.beatmap.version, "url": resp.beatmap.url}, # pyright: ignore[reportOptionalMemberAccess]
|
||||
"mode": resp.beatmap.mode.readable(), # pyright: ignore[reportOptionalMemberAccess]
|
||||
"beatmap": {
|
||||
"title": f"{resp.beatmap.beatmapset.artist} - {resp.beatmap.beatmapset.title} [{resp.beatmap.version}]", # pyright: ignore[reportOptionalMemberAccess]
|
||||
"url": resp.beatmap.url, # pyright: ignore[reportOptionalMemberAccess]
|
||||
},
|
||||
"user": {
|
||||
"username": score.user.username,
|
||||
"url": settings.web_url + "users/" + str(score.user.id),
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.database import (
|
||||
User,
|
||||
UserResp,
|
||||
)
|
||||
from app.database.events import EventResp
|
||||
from app.database.events import Event
|
||||
from app.database.lazer_user import SEARCH_INCLUDED
|
||||
from app.database.pp_best_score import PPBestScore
|
||||
from app.database.score import Score, ScoreResp
|
||||
@@ -100,6 +100,29 @@ async def get_users(
|
||||
return BatchUserResponse(users=users)
|
||||
|
||||
|
||||
@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[Event])
|
||||
async def get_user_events(
|
||||
session: Database,
|
||||
user: int,
|
||||
limit: int | None = Query(None),
|
||||
offset: int | None = Query(None), # TODO: 搞清楚并且添加这个奇怪的分页偏移
|
||||
):
|
||||
db_user = await session.get(User, user)
|
||||
if db_user is None or db_user.id == BANCHOBOT_ID:
|
||||
raise HTTPException(404, "User Not found")
|
||||
events = (
|
||||
await session.exec(
|
||||
select(Event)
|
||||
.where(Event.user_id == db_user.id)
|
||||
.order_by(col(Event.created_at).desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
).all()
|
||||
print(events)
|
||||
return events
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users/{user_id}/{ruleset}",
|
||||
response_model=UserResp,
|
||||
@@ -341,19 +364,3 @@ async def get_user_scores(
|
||||
)
|
||||
|
||||
return score_responses
|
||||
|
||||
|
||||
@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[EventResp])
|
||||
async def get_user_events(
|
||||
session: Database,
|
||||
user: int,
|
||||
limit: int | None = Query(None),
|
||||
offset: str | None = Query(None), # TODO: 搞清楚并且添加这个奇怪的分页偏移
|
||||
):
|
||||
db_user = await session.get(User, user)
|
||||
if db_user is None or db_user.id == BANCHOBOT_ID:
|
||||
raise HTTPException(404, "User Not found")
|
||||
events = await db_user.awaitable_attrs.events
|
||||
if limit is not None:
|
||||
events = events[:limit]
|
||||
return [EventResp(**event.model_dump()).merge_payload() for event in events]
|
||||
|
||||
199
tools/fix_user_rank_event.py
Normal file
199
tools/fix_user_rank_event.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
"""
|
||||
Fix user rank events in the database.
|
||||
|
||||
This script fixes the format of RANK type events by:
|
||||
1. Removing 'Rank.' prefix from scorerank values (e.g., 'Rank.X' -> 'X')
|
||||
2. Converting mode values from enum format to string format (e.g., 'GameMode.OSU' -> 'osu')
|
||||
|
||||
Usage:
|
||||
python tools/fix_user_rank_event.py [--dry-run]
|
||||
|
||||
Options:
|
||||
--dry-run Show what would be changed without making actual changes
|
||||
"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from app.database.events import Event, EventType
|
||||
from app.dependencies.database import engine
|
||||
from app.log import logger
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
logger.remove()
|
||||
|
||||
|
||||
def fix_scorerank(payload: dict) -> tuple[dict, bool]:
|
||||
"""
|
||||
Fix scorerank field by removing 'Rank.' prefix.
|
||||
|
||||
Returns:
|
||||
tuple: (fixed_payload, was_changed)
|
||||
"""
|
||||
fixed_payload = payload.copy()
|
||||
changed = False
|
||||
|
||||
if "scorerank" in fixed_payload:
|
||||
scorerank = str(fixed_payload["scorerank"])
|
||||
if scorerank.startswith("Rank."):
|
||||
fixed_payload["scorerank"] = scorerank.replace("Rank.", "")
|
||||
changed = True
|
||||
|
||||
return fixed_payload, changed
|
||||
|
||||
|
||||
def fix_mode(payload: dict) -> tuple[dict, bool]:
|
||||
"""
|
||||
Fix mode field by converting from enum format to string format.
|
||||
|
||||
Returns:
|
||||
tuple: (fixed_payload, was_changed)
|
||||
"""
|
||||
fixed_payload = payload.copy()
|
||||
changed = False
|
||||
|
||||
if "mode" in fixed_payload:
|
||||
mode = str(fixed_payload["mode"])
|
||||
# Map GameMode enum strings to their values
|
||||
mode_mapping = {
|
||||
"GameMode.OSU": "osu!",
|
||||
"GameMode.TAIKO": "osu!taiko",
|
||||
"GameMode.FRUITS": "osu!catch",
|
||||
"GameMode.MANIA": "osu!mania",
|
||||
"GameMode.OSURX": "osu!relax",
|
||||
"GameMode.OSUAP": "osu!autopilot",
|
||||
"GameMode.TAIKORX": "taiko relax",
|
||||
"GameMode.FRUITSRX": "catch relax",
|
||||
}
|
||||
|
||||
if mode in mode_mapping:
|
||||
fixed_payload["mode"] = mode_mapping[mode]
|
||||
changed = True
|
||||
|
||||
return fixed_payload, changed
|
||||
|
||||
|
||||
def fix_event_payload(payload: dict) -> tuple[dict, bool]:
|
||||
"""
|
||||
Fix both scorerank and mode fields in event payload.
|
||||
|
||||
Returns:
|
||||
tuple: (fixed_payload, was_changed)
|
||||
"""
|
||||
fixed_payload = payload.copy()
|
||||
total_changed = False
|
||||
|
||||
# Fix scorerank
|
||||
fixed_payload, scorerank_changed = fix_scorerank(fixed_payload)
|
||||
|
||||
# Fix mode
|
||||
fixed_payload, mode_changed = fix_mode(fixed_payload)
|
||||
|
||||
total_changed = scorerank_changed or mode_changed
|
||||
|
||||
return fixed_payload, total_changed
|
||||
|
||||
|
||||
async def get_rank_events(session: AsyncSession) -> list[Event]:
|
||||
"""Get all RANK type events from the database."""
|
||||
result = await session.exec(select(Event).where(Event.type == EventType.RANK))
|
||||
return list(result.all())
|
||||
|
||||
|
||||
async def update_event(session: AsyncSession, event: Event, new_payload: dict) -> None:
|
||||
"""Update an event's payload in the database."""
|
||||
|
||||
|
||||
async def main():
|
||||
parser = ArgumentParser(description="Fix user rank events in the database")
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would be changed without making actual changes"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("🔍 Fetching RANK events from database...")
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
events = await get_rank_events(session)
|
||||
print(f"📊 Found {len(events)} RANK events")
|
||||
|
||||
if not events:
|
||||
print("✅ No RANK events found. Nothing to fix.")
|
||||
return
|
||||
|
||||
events_to_fix = []
|
||||
|
||||
# Analyze events
|
||||
for event in events:
|
||||
try:
|
||||
payload = event.event_payload
|
||||
if not isinstance(payload, dict):
|
||||
print(f"⚠️ Event {event.id}: payload is not a dict, skipping")
|
||||
continue
|
||||
|
||||
fixed_payload, needs_fix = fix_event_payload(payload)
|
||||
|
||||
if needs_fix:
|
||||
events_to_fix.append((event, fixed_payload, payload))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing event {event.id}: {e}")
|
||||
continue
|
||||
|
||||
print(f"🔧 Found {len(events_to_fix)} events that need fixing")
|
||||
|
||||
if not events_to_fix:
|
||||
print("✅ All RANK events are already in correct format!")
|
||||
return
|
||||
|
||||
# Show changes
|
||||
for event, fixed_payload, original_payload in events_to_fix:
|
||||
print(f"\n📝 Event {event.id}:")
|
||||
print(f" Original: {original_payload}")
|
||||
print(f" Fixed: {fixed_payload}")
|
||||
|
||||
# Show specific changes
|
||||
changes = []
|
||||
if "scorerank" in original_payload and "scorerank" in fixed_payload:
|
||||
if original_payload["scorerank"] != fixed_payload["scorerank"]:
|
||||
changes.append(f"scorerank: {original_payload['scorerank']} → {fixed_payload['scorerank']}")
|
||||
|
||||
if "mode" in original_payload and "mode" in fixed_payload:
|
||||
if original_payload["mode"] != fixed_payload["mode"]:
|
||||
changes.append(f"mode: {original_payload['mode']} → {fixed_payload['mode']}")
|
||||
|
||||
if changes:
|
||||
print(f" Changes: {', '.join(changes)}")
|
||||
|
||||
if args.dry_run:
|
||||
print(f"\n🧪 DRY RUN: Would fix {len(events_to_fix)} events")
|
||||
print(" Run without --dry-run to apply changes")
|
||||
return
|
||||
|
||||
# Apply changes
|
||||
print(f"\n💾 Applying fixes to {len(events_to_fix)} events...")
|
||||
|
||||
try:
|
||||
for event, fixed_payload, _ in events_to_fix:
|
||||
event.event_payload = fixed_payload
|
||||
|
||||
await session.commit()
|
||||
print(f"✅ Successfully fixed {len(events_to_fix)} events!")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(f"❌ Error applying fixes: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user