feat(score): support rx for taiko & catch
This commit is contained in:
@@ -49,8 +49,8 @@ LOG_LEVEL="INFO"
|
|||||||
SENTRY_DSN
|
SENTRY_DSN
|
||||||
|
|
||||||
# 游戏设置
|
# 游戏设置
|
||||||
ENABLE_OSU_RX=false # 启用 osu!RX 统计数据
|
ENABLE_RX=false # 启用 RX mod 统计数据
|
||||||
ENABLE_OSU_AP=false # 启用 osu!AP 统计数据
|
ENABLE_AP=false # 启用 AP mod Z统计数据
|
||||||
ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算
|
ENABLE_ALL_MODS_PP=false # 启用所有 Mod 的 PP 计算
|
||||||
ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态
|
ENABLE_SUPPORTER_FOR_ALL_USERS=false # 启用所有新注册用户的支持者状态
|
||||||
ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回)
|
ENABLE_ALL_BEATMAP_LEADERBOARD=false # 启用所有谱面的排行榜(没有排行榜的谱面会以 APPROVED 状态返回)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
|
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
|
||||||
- **用户数据管理**: 完整的用户信息、统计数据、成就等
|
- **用户数据管理**: 完整的用户信息、统计数据、成就等
|
||||||
- **多游戏模式支持**: osu! (osu!rx, osu!ap), taiko, fruits, mania
|
- **多游戏模式支持**: osu! (RX, AP), taiko (RX), catch (RX), mania
|
||||||
- **数据库持久化**: MySQL 存储用户数据
|
- **数据库持久化**: MySQL 存储用户数据
|
||||||
- **缓存支持**: Redis 缓存令牌和会话信息
|
- **缓存支持**: Redis 缓存令牌和会话信息
|
||||||
- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3
|
- **多种存储后端**: 支持本地存储、Cloudflare R2、AWS S3
|
||||||
@@ -109,8 +109,8 @@ Fetcher 用于从 osu! 官方 API 获取数据,使用 osu! 官方 API 的 OAut
|
|||||||
### 游戏设置
|
### 游戏设置
|
||||||
| 变量名 | 描述 | 默认值 |
|
| 变量名 | 描述 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| `ENABLE_OSU_RX` | 启用 osu!RX 统计数据 | `false` |
|
| `ENABLE_RX` | 启用 RX mod 统计数据 | `false` |
|
||||||
| `ENABLE_OSU_AP` | 启用 osu!AP 统计数据 | `false` |
|
| `ENABLE_AP` | 启用 AP mod 统计数据 | `false` |
|
||||||
| `ENABLE_ALL_MODS_PP` | 启用所有 Mod 的 PP 计算 | `false` |
|
| `ENABLE_ALL_MODS_PP` | 启用所有 Mod 的 PP 计算 | `false` |
|
||||||
| `ENABLE_SUPPORTER_FOR_ALL_USERS` | 启用所有新注册用户的支持者状态 | `false` |
|
| `ENABLE_SUPPORTER_FOR_ALL_USERS` | 启用所有新注册用户的支持者状态 | `false` |
|
||||||
| `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` |
|
| `ENABLE_ALL_BEATMAP_LEADERBOARD` | 启用所有谱面的排行榜 | `false` |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import Field, HttpUrl, ValidationInfo, field_validator
|
from pydantic import AliasChoices, Field, HttpUrl, ValidationInfo, field_validator
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -97,8 +97,12 @@ class Settings(BaseSettings):
|
|||||||
sentry_dsn: HttpUrl | None = None
|
sentry_dsn: HttpUrl | None = None
|
||||||
|
|
||||||
# 游戏设置
|
# 游戏设置
|
||||||
enable_osu_rx: bool = False
|
enable_rx: bool = Field(
|
||||||
enable_osu_ap: bool = False
|
default=False, validation_alias=AliasChoices("enable_rx", "enable_osu_rx")
|
||||||
|
)
|
||||||
|
enable_ap: bool = Field(
|
||||||
|
default=False, validation_alias=AliasChoices("enable_ap", "enable_osu_ap")
|
||||||
|
)
|
||||||
enable_all_mods_pp: bool = False
|
enable_all_mods_pp: bool = False
|
||||||
enable_supporter_for_all_users: bool = False
|
enable_supporter_for_all_users: bool = False
|
||||||
enable_all_beatmap_leaderboard: bool = False
|
enable_all_beatmap_leaderboard: bool = False
|
||||||
|
|||||||
@@ -330,12 +330,8 @@ async def get_leaderboard(
|
|||||||
user: User | None = None,
|
user: User | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> tuple[list[Score], Score | None]:
|
) -> tuple[list[Score], Score | None]:
|
||||||
is_rx = "RX" in (mods or [])
|
mods = mods or []
|
||||||
is_ap = "AP" in (mods or [])
|
mode = mode.to_special_mode(mods)
|
||||||
if settings.enable_osu_rx and is_rx:
|
|
||||||
mode = GameMode.OSURX
|
|
||||||
elif settings.enable_osu_ap and is_ap:
|
|
||||||
mode = GameMode.OSUAP
|
|
||||||
|
|
||||||
wheres = await _score_where(type, beatmap, mode, mods, user)
|
wheres = await _score_where(type, beatmap, mode, mods, user)
|
||||||
if wheres is None:
|
if wheres is None:
|
||||||
@@ -696,14 +692,7 @@ async def process_score(
|
|||||||
) -> Score:
|
) -> Score:
|
||||||
assert user.id
|
assert user.id
|
||||||
can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods)
|
can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods)
|
||||||
acronyms = [mod["acronym"] for mod in info.mods]
|
gamemode = GameMode.from_int(info.ruleset_id).to_special_mode(info.mods)
|
||||||
is_rx = "RX" in acronyms
|
|
||||||
is_ap = "AP" in acronyms
|
|
||||||
gamemode = GameMode.from_int(info.ruleset_id)
|
|
||||||
if settings.enable_osu_rx and is_rx and gamemode == GameMode.OSU:
|
|
||||||
gamemode = GameMode.OSURX
|
|
||||||
elif settings.enable_osu_ap and is_ap and gamemode == GameMode.OSU:
|
|
||||||
gamemode = GameMode.OSUAP
|
|
||||||
score = Score(
|
score = Score(
|
||||||
accuracy=info.accuracy,
|
accuracy=info.accuracy,
|
||||||
max_combo=info.max_combo,
|
max_combo=info.max_combo,
|
||||||
|
|||||||
@@ -160,9 +160,13 @@ def mods_can_get_pp(ruleset_id: int, mods: list[APIMod]) -> bool:
|
|||||||
return True
|
return True
|
||||||
ranked_mods = RANKED_MODS[ruleset_id]
|
ranked_mods = RANKED_MODS[ruleset_id]
|
||||||
for mod in mods:
|
for mod in mods:
|
||||||
if app_settings.enable_osu_rx and mod["acronym"] == "RX" and ruleset_id == 0:
|
if (
|
||||||
|
app_settings.enable_rx
|
||||||
|
and mod["acronym"] == "RX"
|
||||||
|
and ruleset_id in {0, 1, 2}
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if app_settings.enable_osu_ap and mod["acronym"] == "AP" and ruleset_id == 0:
|
if app_settings.enable_ap and mod["acronym"] == "AP" and ruleset_id == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mod["settings"] = mod.get("settings", {})
|
mod["settings"] = mod.get("settings", {})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Literal, TypedDict
|
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
from .mods import API_MODS, APIMod, init_mods
|
from .mods import API_MODS, APIMod, init_mods
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ class GameMode(str, Enum):
|
|||||||
MANIA = "mania"
|
MANIA = "mania"
|
||||||
OSURX = "osurx"
|
OSURX = "osurx"
|
||||||
OSUAP = "osuap"
|
OSUAP = "osuap"
|
||||||
|
TAIKORX = "taikorx"
|
||||||
|
FRUITSRX = "fruitsrx"
|
||||||
|
|
||||||
def to_rosu(self) -> "rosu.GameMode":
|
def to_rosu(self) -> "rosu.GameMode":
|
||||||
import rosu_pp_py as rosu
|
import rosu_pp_py as rosu
|
||||||
@@ -29,6 +33,8 @@ class GameMode(str, Enum):
|
|||||||
GameMode.MANIA: rosu.GameMode.Mania,
|
GameMode.MANIA: rosu.GameMode.Mania,
|
||||||
GameMode.OSURX: rosu.GameMode.Osu,
|
GameMode.OSURX: rosu.GameMode.Osu,
|
||||||
GameMode.OSUAP: rosu.GameMode.Osu,
|
GameMode.OSUAP: rosu.GameMode.Osu,
|
||||||
|
GameMode.TAIKORX: rosu.GameMode.Taiko,
|
||||||
|
GameMode.FRUITSRX: rosu.GameMode.Catch,
|
||||||
}[self]
|
}[self]
|
||||||
|
|
||||||
def __int__(self) -> int:
|
def __int__(self) -> int:
|
||||||
@@ -39,6 +45,8 @@ class GameMode(str, Enum):
|
|||||||
GameMode.MANIA: 3,
|
GameMode.MANIA: 3,
|
||||||
GameMode.OSURX: 0,
|
GameMode.OSURX: 0,
|
||||||
GameMode.OSUAP: 0,
|
GameMode.OSUAP: 0,
|
||||||
|
GameMode.TAIKORX: 1,
|
||||||
|
GameMode.FRUITSRX: 2,
|
||||||
}[self]
|
}[self]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -59,8 +67,27 @@ class GameMode(str, Enum):
|
|||||||
3: GameMode.MANIA,
|
3: GameMode.MANIA,
|
||||||
4: GameMode.OSURX,
|
4: GameMode.OSURX,
|
||||||
5: GameMode.OSUAP,
|
5: GameMode.OSUAP,
|
||||||
|
6: GameMode.TAIKORX,
|
||||||
|
7: GameMode.FRUITSRX,
|
||||||
}[v]
|
}[v]
|
||||||
|
|
||||||
|
def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode":
|
||||||
|
if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS):
|
||||||
|
return self
|
||||||
|
if not settings.enable_rx and not settings.enable_ap:
|
||||||
|
return self
|
||||||
|
if len(mods) > 0 and isinstance(mods[0], dict):
|
||||||
|
mods = [mod["acronym"] for mod in cast(list[APIMod], mods)]
|
||||||
|
if "AP" in mods and settings.enable_ap:
|
||||||
|
return GameMode.OSUAP
|
||||||
|
if "RX" in mods and settings.enable_rx:
|
||||||
|
return {
|
||||||
|
GameMode.OSU: GameMode.OSURX,
|
||||||
|
GameMode.TAIKO: GameMode.TAIKORX,
|
||||||
|
GameMode.FRUITS: GameMode.FRUITSRX,
|
||||||
|
}[self]
|
||||||
|
raise ValueError(f"Unknown game mode: {self}")
|
||||||
|
|
||||||
|
|
||||||
class Rank(str, Enum):
|
class Rank(str, Enum):
|
||||||
X = "X"
|
X = "X"
|
||||||
|
|||||||
@@ -175,10 +175,11 @@ async def register_user(
|
|||||||
for i in [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA]:
|
for i in [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA]:
|
||||||
statistics = UserStatistics(mode=i, user_id=new_user.id)
|
statistics = UserStatistics(mode=i, user_id=new_user.id)
|
||||||
db.add(statistics)
|
db.add(statistics)
|
||||||
if settings.enable_osu_rx:
|
if settings.enable_rx:
|
||||||
statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=new_user.id)
|
for mode in (GameMode.OSURX, GameMode.TAIKORX, GameMode.FRUITSRX):
|
||||||
db.add(statistics_rx)
|
statistics_rx = UserStatistics(mode=mode, user_id=new_user.id)
|
||||||
if settings.enable_osu_ap:
|
db.add(statistics_rx)
|
||||||
|
if settings.enable_ap:
|
||||||
statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=new_user.id)
|
statistics_ap = UserStatistics(mode=GameMode.OSUAP, user_id=new_user.id)
|
||||||
db.add(statistics_ap)
|
db.add(statistics_ap)
|
||||||
daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id)
|
daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id)
|
||||||
|
|||||||
@@ -15,19 +15,24 @@ async def create_rx_statistics():
|
|||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
users = (await session.exec(select(User.id))).all()
|
users = (await session.exec(select(User.id))).all()
|
||||||
for i in users:
|
for i in users:
|
||||||
if settings.enable_osu_rx:
|
if settings.enable_rx:
|
||||||
is_exist = (
|
for mode in (
|
||||||
await session.exec(
|
GameMode.OSURX,
|
||||||
select(exists()).where(
|
GameMode.TAIKORX,
|
||||||
UserStatistics.user_id == i,
|
GameMode.FRUITSRX,
|
||||||
UserStatistics.mode == GameMode.OSURX,
|
):
|
||||||
|
is_exist = (
|
||||||
|
await session.exec(
|
||||||
|
select(exists()).where(
|
||||||
|
UserStatistics.user_id == i,
|
||||||
|
UserStatistics.mode == mode,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
).first()
|
||||||
).first()
|
if not is_exist:
|
||||||
if not is_exist:
|
statistics_rx = UserStatistics(mode=mode, user_id=i)
|
||||||
statistics_rx = UserStatistics(mode=GameMode.OSURX, user_id=i)
|
session.add(statistics_rx)
|
||||||
session.add(statistics_rx)
|
if settings.enable_ap:
|
||||||
if settings.enable_osu_ap:
|
|
||||||
is_exist = (
|
is_exist = (
|
||||||
await session.exec(
|
await session.exec(
|
||||||
select(exists()).where(
|
select(exists()).where(
|
||||||
|
|||||||
162
migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py
Normal file
162
migrations/versions/951a2188e691_score_add_rx_for_taiko_catch.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""score: add rx for taiko & catch
|
||||||
|
|
||||||
|
Revision ID: 951a2188e691
|
||||||
|
Revises: 7e9d5e012d37
|
||||||
|
Create Date: 2025-08-15 04:38:07.595003
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "951a2188e691"
|
||||||
|
down_revision: str | Sequence[str] | None = "7e9d5e012d37"
|
||||||
|
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.alter_column(
|
||||||
|
"beatmaps",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"best_scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"lazer_user_statistics",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"lazer_users",
|
||||||
|
"playmode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"score_tokens",
|
||||||
|
"ruleset_id",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"total_score_best_scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"rank_history",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"rank_top",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column(
|
||||||
|
"beatmaps",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"best_scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"lazer_user_statistics",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"lazer_users",
|
||||||
|
"playmode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"score_tokens",
|
||||||
|
"ruleset_id",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"total_score_best_scores",
|
||||||
|
"gamemode",
|
||||||
|
existing_type=mysql.ENUM("OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"rank_top",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"rank_top",
|
||||||
|
"mode",
|
||||||
|
existing_type=mysql.ENUM(
|
||||||
|
"OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", "TAIKORX", "FRUITSRX"
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user