feat(notification): support notification

This commit is contained in:
MingxuanGame
2025-08-21 07:22:44 +00:00
parent 6ac9a124ea
commit 9fb0d0c198
13 changed files with 626 additions and 70 deletions

View File

@@ -29,6 +29,7 @@ from .lazer_user import (
UserResp,
)
from .multiplayer_event import MultiplayerEvent, MultiplayerEventResp
from .notification import Notification, UserNotification
from .playlist_attempts import (
ItemAttemptsCount,
ItemAttemptsResp,
@@ -86,6 +87,7 @@ __all__ = [
"MultiplayerEvent",
"MultiplayerEventResp",
"MultiplayerScores",
"Notification",
"OAuthClient",
"OAuthToken",
"PPBestScore",
@@ -120,6 +122,7 @@ __all__ = [
"UserAchievement",
"UserAchievementResp",
"UserLoginLog",
"UserNotification",
"UserResp",
"UserStatistics",
"UserStatisticsResp",

View File

@@ -190,6 +190,7 @@ class ChatMessageBase(UTCBaseModel, SQLModel):
class ChatMessage(ChatMessageBase, table=True):
__tablename__ = "chat_messages" # pyright: ignore[reportAssignmentType]
user: User = Relationship(sa_relationship_kwargs={"lazy": "joined"})
channel: ChatChannel = Relationship()
class ChatMessageResp(ChatMessageBase):

View File

@@ -0,0 +1,73 @@
from datetime import UTC, datetime
from typing import Any
from app.models.notification import NotificationDetail, NotificationName
from sqlmodel import (
JSON,
BigInteger,
Column,
DateTime,
Field,
ForeignKey,
Relationship,
SQLModel,
)
from sqlmodel.ext.asyncio.session import AsyncSession
class Notification(SQLModel, table=True):
__tablename__ = "notifications" # pyright: ignore[reportAssignmentType]
id: int = Field(primary_key=True, index=True, default=None)
name: NotificationName = Field(index=True)
category: str = Field(max_length=255, index=True)
created_at: datetime = Field(sa_column=Column(DateTime))
object_type: str = Field(index=True)
object_id: int = Field(sa_column=Column(BigInteger, index=True))
source_user_id: int = Field(index=True)
details: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
class UserNotification(SQLModel, table=True):
__tablename__ = "user_notifications" # pyright: ignore[reportAssignmentType]
id: int = Field(
sa_column=Column(
BigInteger,
primary_key=True,
index=True,
),
default=None,
)
notification_id: int = Field(index=True, foreign_key="notifications.id")
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
is_read: bool = Field(index=True)
notification: Notification = Relationship(sa_relationship_kwargs={"lazy": "joined"})
async def insert_notification(session: AsyncSession, detail: NotificationDetail):
notification = Notification(
name=detail.name,
category=detail.name.category,
object_type=detail.object_type,
object_id=detail.object_id,
source_user_id=detail.source_user_id,
details=detail.model_dump(),
created_at=datetime.now(UTC),
)
session.add(notification)
await session.commit()
await session.refresh(notification)
id_ = notification.id
for receiver in await detail.get_receivers(session):
user_notification = UserNotification(
notification_id=id_,
user_id=receiver,
is_read=False,
)
session.add(user_notification)
await session.commit()
return id_

184
app/models/notification.py Normal file
View File

@@ -0,0 +1,184 @@
from __future__ import annotations
from abc import abstractmethod
from enum import Enum
from typing import TYPE_CHECKING
from app.utils import truncate
from pydantic import BaseModel, PrivateAttr
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
CONTENT_TRUNCATE = 36
if TYPE_CHECKING:
from app.database import ChannelType, ChatMessage, User
# https://github.com/ppy/osu-web/blob/master/app/Models/Notification.php
class NotificationName(str, Enum):
BEATMAP_OWNER_CHANGE = "beatmap_owner_change"
BEATMAPSET_DISCUSSION_LOCK = "beatmapset_discussion_lock"
BEATMAPSET_DISCUSSION_POST_NEW = "beatmapset_discussion_post_new"
BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM = "beatmapset_discussion_qualified_problem"
BEATMAPSET_DISCUSSION_REVIEW_NEW = "beatmapset_discussion_review_new"
BEATMAPSET_DISCUSSION_UNLOCK = "beatmapset_discussion_unlock"
BEATMAPSET_DISQUALIFY = "beatmapset_disqualify"
BEATMAPSET_LOVE = "beatmapset_love"
BEATMAPSET_NOMINATE = "beatmapset_nominate"
BEATMAPSET_QUALIFY = "beatmapset_qualify"
BEATMAPSET_RANK = "beatmapset_rank"
BEATMAPSET_REMOVE_FROM_LOVED = "beatmapset_remove_from_loved"
BEATMAPSET_RESET_NOMINATIONS = "beatmapset_reset_nominations"
CHANNEL_ANNOUNCEMENT = "channel_announcement"
CHANNEL_MESSAGE = "channel_message"
CHANNEL_TEAM = "channel_team"
COMMENT_NEW = "comment_new"
FORUM_TOPIC_REPLY = "forum_topic_reply"
TEAM_APPLICATION_ACCEPT = "team_application_accept"
TEAM_APPLICATION_REJECT = "team_application_reject"
TEAM_APPLICATION_STORE = "team_application_store"
USER_ACHIEVEMENT_UNLOCK = "user_achievement_unlock"
USER_BEATMAPSET_NEW = "user_beatmapset_new"
USER_BEATMAPSET_REVIVE = "user_beatmapset_revive"
# NAME_TO_CATEGORY
@property
def category(self) -> str:
return {
NotificationName.BEATMAP_OWNER_CHANGE: "beatmap_owner_change",
NotificationName.BEATMAPSET_DISCUSSION_LOCK: "beatmapset_discussion",
NotificationName.BEATMAPSET_DISCUSSION_POST_NEW: "beatmapset_discussion",
NotificationName.BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM: "beatmapset_problem", # noqa: E501
NotificationName.BEATMAPSET_DISCUSSION_REVIEW_NEW: "beatmapset_discussion",
NotificationName.BEATMAPSET_DISCUSSION_UNLOCK: "beatmapset_discussion",
NotificationName.BEATMAPSET_DISQUALIFY: "beatmapset_state",
NotificationName.BEATMAPSET_LOVE: "beatmapset_state",
NotificationName.BEATMAPSET_NOMINATE: "beatmapset_state",
NotificationName.BEATMAPSET_QUALIFY: "beatmapset_state",
NotificationName.BEATMAPSET_RANK: "beatmapset_state",
NotificationName.BEATMAPSET_REMOVE_FROM_LOVED: "beatmapset_state",
NotificationName.BEATMAPSET_RESET_NOMINATIONS: "beatmapset_state",
NotificationName.CHANNEL_ANNOUNCEMENT: "announcement",
NotificationName.CHANNEL_MESSAGE: "channel",
NotificationName.CHANNEL_TEAM: "channel_team",
NotificationName.COMMENT_NEW: "comment",
NotificationName.FORUM_TOPIC_REPLY: "forum_topic_reply",
NotificationName.TEAM_APPLICATION_ACCEPT: "team_application",
NotificationName.TEAM_APPLICATION_REJECT: "team_application",
NotificationName.TEAM_APPLICATION_STORE: "team_application",
NotificationName.USER_ACHIEVEMENT_UNLOCK: "user_achievement_unlock",
NotificationName.USER_BEATMAPSET_NEW: "user_beatmapset_new",
NotificationName.USER_BEATMAPSET_REVIVE: "user_beatmapset_new",
}[self]
class NotificationDetail(BaseModel):
@property
@abstractmethod
def name(self) -> NotificationName:
raise NotImplementedError
@property
@abstractmethod
def object_type(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def object_id(self) -> int:
raise NotImplementedError
@property
@abstractmethod
def source_user_id(self) -> int:
raise NotImplementedError
@abstractmethod
async def get_receivers(self, session: AsyncSession) -> list[int]:
raise NotImplementedError
class ChannelMessageBase(NotificationDetail):
title: str = ""
type: str = ""
cover_url: str = ""
_message: "ChatMessage" = PrivateAttr()
_user: "User" = PrivateAttr()
_receiver: list[int] = PrivateAttr()
def __init__(
self,
message: "ChatMessage",
user: "User",
receiver: list[int],
channel_type: "ChannelType",
) -> None:
super().__init__(
title=truncate(message.content, CONTENT_TRUNCATE),
type=channel_type.value.lower(),
cover_url=user.avatar_url,
)
self._message = message
self._user = user
self._receiver = receiver
async def get_receivers(self, session: AsyncSession) -> list[int]:
return self._receiver
@property
def source_user_id(self) -> int:
return self._user.id
@property
def object_type(self) -> str:
return "channel"
@property
def object_id(self) -> int:
return self._message.channel_id
class ChannelMessage(ChannelMessageBase):
def __init__(
self,
message: "ChatMessage",
user: "User",
receiver: list[int],
channel_type: "ChannelType",
) -> None:
super().__init__(message, user, receiver, channel_type)
@property
def name(self) -> NotificationName:
return NotificationName.CHANNEL_MESSAGE
class ChannelMessageTeam(ChannelMessageBase):
def __init__(self, message: "ChatMessage", user: "User") -> None:
from app.database import ChannelType
super().__init__(message, user, [], ChannelType.TEAM)
@property
def name(self) -> NotificationName:
return NotificationName.CHANNEL_TEAM
async def get_receivers(self, session: AsyncSession) -> list[int]:
from app.database import TeamMember
user_team_id = (
await session.exec(
select(TeamMember.team_id).where(TeamMember.user_id == self._user.id)
)
).first()
if not user_team_id:
return []
user_ids = (
await session.exec(
select(TeamMember.user_id).where(TeamMember.team_id == user_team_id)
)
).all()
return list(user_ids)

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
from app.signalr import signalr_router as signalr_router
from .auth import router as auth_router
from .chat import chat_router as chat_router
from .fetcher import fetcher_router as fetcher_router
from .file import file_router as file_router
from .notification import chat_router as chat_router
from .private import private_router as private_router
from .redirect import (
redirect_api_router as redirect_api_router,

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from app.config import settings
from app.router.v2 import api_v2_router as router
from . import channel, message # noqa: F401
from .server import chat_router as chat_router
from fastapi import Query
__all__ = ["chat_router"]
@router.get(
"/notifications",
tags=["通知", "聊天"],
name="获取通知",
description="获取当前用户未读通知。根据 ID 排序。同时返回通知服务器入口。",
)
async def get_notifications(
max_id: int | None = Query(None, description="获取 ID 小于此值的通知"),
):
if settings.server_url is not None:
notification_endpoint = f"{settings.server_url}notification-server".replace(
"http://", "ws://"
).replace("https://", "wss://")
else:
notification_endpoint = "/notification-server"
return {
"has_more": False,
"notifications": [],
"unread_count": 0,
"notification_endpoint": notification_endpoint,
}

View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from datetime import UTC, datetime
from app.config import settings
from app.database.lazer_user import User
from app.database.notification import Notification, UserNotification
from app.dependencies.database import Database
from app.dependencies.user import get_client_user
from app.models.chat import ChatEvent
from app.router.v2 import api_v2_router as router
from . import channel, message # noqa: F401
from .server import (
chat_router as chat_router,
server,
)
from fastapi import Body, Query, Security
from pydantic import BaseModel
from sqlmodel import col, func, select
__all__ = ["chat_router"]
class NotificationResp(BaseModel):
has_more: bool
notifications: list[Notification]
unread_count: int
notification_endpoint: str
@router.get(
"/notifications",
tags=["通知", "聊天"],
name="获取通知",
description="获取当前用户未读通知。根据 ID 排序。同时返回通知服务器入口。",
response_model=NotificationResp,
)
async def get_notifications(
session: Database,
max_id: int | None = Query(None, description="获取 ID 小于此值的通知"),
current_user: User = Security(get_client_user),
):
if settings.server_url is not None:
notification_endpoint = f"{settings.server_url}notification-server".replace(
"http://", "ws://"
).replace("https://", "wss://")
else:
notification_endpoint = "/notification-server"
query = select(UserNotification).where(
UserNotification.user_id == current_user.id,
col(UserNotification.is_read).is_(False),
)
if max_id is not None:
query = query.where(UserNotification.notification_id < max_id)
notifications = (await session.exec(query)).all()
total_count = (
await session.exec(
select(func.count())
.select_from(UserNotification)
.where(
UserNotification.user_id == current_user.id,
col(UserNotification.is_read).is_(False),
)
)
).one()
unread_count = len(notifications)
return NotificationResp(
has_more=unread_count < total_count,
notifications=[notification.notification for notification in notifications],
unread_count=unread_count,
notification_endpoint=notification_endpoint,
)
class _IdentityReq(BaseModel):
category: str | None = None
id: int | None = None
object_id: int | None = None
object_type: int | None = None
async def _get_notifications(
session: Database, current_user: User, identities: list[_IdentityReq]
) -> list[UserNotification]:
result: dict[int, UserNotification] = {}
base_query = select(UserNotification).where(
UserNotification.user_id == current_user.id,
col(UserNotification.is_read).is_(False),
)
for identity in identities:
query = base_query
if identity.id is not None:
query = base_query.where(UserNotification.notification_id == identity.id)
if identity.object_id is not None:
query = base_query.where(
col(UserNotification.notification).has(
col(Notification.object_id) == identity.object_id
)
)
if identity.object_type is not None:
query = base_query.where(
col(UserNotification.notification).has(
col(Notification.object_type) == identity.object_type
)
)
if identity.category is not None:
query = base_query.where(
col(UserNotification.notification).has(
col(Notification.category) == identity.category
)
)
result.update({n.notification_id: n for n in await session.exec(query)})
return list(result.values())
@router.post(
"/notifications/mark-read",
tags=["通知", "聊天"],
name="标记通知为已读",
description="标记当前用户的通知为已读。",
status_code=204,
)
async def mark_notifications_as_read(
session: Database,
identities: list[_IdentityReq] = Body(default_factory=list),
notifications: list[_IdentityReq] = Body(default_factory=list),
current_user: User = Security(get_client_user),
):
identities.extend(notifications)
user_notifications = await _get_notifications(session, current_user, identities)
for user_notification in user_notifications:
user_notification.is_read = True
assert current_user.id
await server.send_event(
current_user.id,
ChatEvent(
event="read",
data={
"notifications": [i.model_dump() for i in identities],
"read_count": len(user_notifications),
"timestamp": datetime.now(UTC).isoformat(),
},
),
)
await session.commit()

View File

@@ -14,6 +14,7 @@ from app.database.lazer_user import User
from app.dependencies.database import Database, get_redis
from app.dependencies.param import BodyOrForm
from app.dependencies.user import get_current_user
from app.models.notification import ChannelMessage, ChannelMessageTeam
from app.router.v2 import api_v2_router as router
from .banchobot import bot
@@ -113,6 +114,15 @@ async def send_message(
)
if is_bot_command:
await bot.try_handle(current_user, db_channel, req.message, session)
if db_channel.type == ChannelType.PM:
user_ids = db_channel.name.split("_")[1:]
await server.new_private_notification(
ChannelMessage(
msg, current_user, [int(u) for u in user_ids], db_channel.type
)
)
elif db_channel.type == ChannelType.TEAM:
await server.new_private_notification(ChannelMessageTeam(msg, current_user))
return resp

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import asyncio
from typing import overload
from app.database.chat import ChannelType, ChatChannel, ChatChannelResp, ChatMessageResp
from app.database.lazer_user import User
from app.database.notification import UserNotification, insert_notification
from app.dependencies.database import (
DBFactory,
get_db_factory,
@@ -13,12 +15,14 @@ from app.dependencies.database import (
from app.dependencies.user import get_current_user
from app.log import logger
from app.models.chat import ChatEvent
from app.models.notification import NotificationDetail
from app.service.subscribers.chat import ChatSubscriber
from fastapi import APIRouter, Depends, Header, WebSocket, WebSocketDisconnect
from fastapi.security import SecurityScopes
from fastapi.websockets import WebSocketState
from redis.asyncio import Redis
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -59,15 +63,24 @@ class ChatServer:
if channel:
await self.leave_channel(user, channel, session)
async def send_event(self, client: WebSocket, event: ChatEvent):
@overload
async def send_event(self, client: int, event: ChatEvent): ...
@overload
async def send_event(self, client: WebSocket, event: ChatEvent): ...
async def send_event(self, client: WebSocket | int, event: ChatEvent):
if isinstance(client, int):
client_ = self.connect_client.get(client)
if client_ is None:
return
client = client_
if client.client_state == WebSocketState.CONNECTED:
await client.send_text(event.model_dump_json())
async def broadcast(self, channel_id: int, event: ChatEvent):
for user_id in self.channels.get(channel_id, []):
client = self.connect_client.get(user_id)
if client:
await self.send_event(client, event)
await self.send_event(user_id, event)
async def mark_as_read(self, channel_id: int, user_id: int, message_id: int):
await self.redis.set(f"chat:{channel_id}:last_read:{user_id}", message_id)
@@ -80,9 +93,7 @@ class ChatServer:
data={"messages": [message], "users": [message.sender]},
)
if is_bot_command:
client = self.connect_client.get(message.sender_id)
if client:
self._add_task(self.send_event(client, event))
self._add_task(self.send_event(message.sender_id, event))
else:
self._add_task(
self.broadcast(
@@ -123,15 +134,13 @@ class ChatServer:
if channel.type != ChannelType.PUBLIC
else None,
)
client = self.connect_client.get(user.id)
if client:
await self.send_event(
client,
ChatEvent(
event="chat.channel.join",
data=channel_resp.model_dump(),
),
)
await self.send_event(
user.id,
ChatEvent(
event="chat.channel.join",
data=channel_resp.model_dump(),
),
)
async def join_channel(
self, user: User, channel: ChatChannel, session: AsyncSession
@@ -154,15 +163,13 @@ class ChatServer:
self.channels[channel_id] if channel.type != ChannelType.PUBLIC else None,
)
client = self.connect_client.get(user_id)
if client:
await self.send_event(
client,
ChatEvent(
event="chat.channel.join",
data=channel_resp.model_dump(),
),
)
await self.send_event(
user_id,
ChatEvent(
event="chat.channel.join",
data=channel_resp.model_dump(),
),
)
return channel_resp
@@ -189,15 +196,13 @@ class ChatServer:
if channel.type != ChannelType.PUBLIC
else None,
)
client = self.connect_client.get(user_id)
if client:
await self.send_event(
client,
ChatEvent(
event="chat.channel.part",
data=channel_resp.model_dump(),
),
)
await self.send_event(
user_id,
ChatEvent(
event="chat.channel.part",
data=channel_resp.model_dump(),
),
)
async def join_room_channel(self, channel_id: int, user_id: int):
async with with_db() as session:
@@ -223,6 +228,28 @@ class ChatServer:
await self.leave_channel(user, channel, session)
async def new_private_notification(self, detail: NotificationDetail):
async with with_db() as session:
id = await insert_notification(session, detail)
users = (
await session.exec(
select(UserNotification).where(
UserNotification.notification_id == id
)
)
).all()
for user_notification in users:
data = user_notification.notification.model_dump()
data["is_read"] = user_notification.is_read
data["details"] = user_notification.notification.details
await server.send_event(
user_notification.user_id,
ChatEvent(
event="new",
data=data,
),
)
server = ChatServer()

View File

@@ -118,3 +118,9 @@ def are_adjacent_weeks(dt1: datetime, dt2: datetime) -> bool:
def are_same_weeks(dt1: datetime, dt2: datetime) -> bool:
return dt1.isocalendar()[:2] == dt2.isocalendar()[:2]
def truncate(text: str, limit: int = 100, ellipsis: str = "...") -> str:
if len(text) > limit:
return text[:limit] + ellipsis
return text

View File

@@ -0,0 +1,138 @@
"""notification: add notification
Revision ID: 4f46c43d8601
Revises: 2fcfc28846c1
Create Date: 2025-08-21 07:03:45.813547
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = "4f46c43d8601"
down_revision: str | Sequence[str] | None = "2fcfc28846c1"
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.create_table(
"notifications",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"name",
sa.Enum(
"BEATMAP_OWNER_CHANGE",
"BEATMAPSET_DISCUSSION_LOCK",
"BEATMAPSET_DISCUSSION_POST_NEW",
"BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM",
"BEATMAPSET_DISCUSSION_REVIEW_NEW",
"BEATMAPSET_DISCUSSION_UNLOCK",
"BEATMAPSET_DISQUALIFY",
"BEATMAPSET_LOVE",
"BEATMAPSET_NOMINATE",
"BEATMAPSET_QUALIFY",
"BEATMAPSET_RANK",
"BEATMAPSET_REMOVE_FROM_LOVED",
"BEATMAPSET_RESET_NOMINATIONS",
"CHANNEL_ANNOUNCEMENT",
"CHANNEL_MESSAGE",
"CHANNEL_TEAM",
"COMMENT_NEW",
"FORUM_TOPIC_REPLY",
"TEAM_APPLICATION_ACCEPT",
"TEAM_APPLICATION_REJECT",
"TEAM_APPLICATION_STORE",
"USER_ACHIEVEMENT_UNLOCK",
"USER_BEATMAPSET_NEW",
"USER_BEATMAPSET_REVIVE",
name="notificationname",
),
nullable=False,
),
sa.Column(
"category", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("object_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("object_id", sa.BigInteger(), nullable=True),
sa.Column("source_user_id", sa.Integer(), nullable=False),
sa.Column("details", sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_notifications_category"), "notifications", ["category"], unique=False
)
op.create_index(op.f("ix_notifications_id"), "notifications", ["id"], unique=False)
op.create_index(
op.f("ix_notifications_name"), "notifications", ["name"], unique=False
)
op.create_index(
op.f("ix_notifications_object_id"), "notifications", ["object_id"], unique=False
)
op.create_index(
op.f("ix_notifications_object_type"),
"notifications",
["object_type"],
unique=False,
)
op.create_index(
op.f("ix_notifications_source_user_id"),
"notifications",
["source_user_id"],
unique=False,
)
op.create_table(
"user_notifications",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column("notification_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("is_read", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["notification_id"],
["notifications.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["lazer_users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_user_notifications_id"), "user_notifications", ["id"], unique=False
)
op.create_index(
op.f("ix_user_notifications_is_read"),
"user_notifications",
["is_read"],
unique=False,
)
op.create_index(
op.f("ix_user_notifications_notification_id"),
"user_notifications",
["notification_id"],
unique=False,
)
op.create_index(
op.f("ix_user_notifications_user_id"),
"user_notifications",
["user_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user_notifications")
op.drop_table("notifications")
# ### end Alembic commands ###