Add notification status to users table in AP
This commit is contained in:
@@ -64,6 +64,28 @@ async def log_admin_action(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_user_response(user: User, marathons_count: int) -> AdminUserResponse:
|
||||||
|
"""Build AdminUserResponse from User model."""
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
notify_events=user.notify_events,
|
||||||
|
notify_disputes=user.notify_disputes,
|
||||||
|
notify_moderation=user.notify_moderation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[AdminUserResponse])
|
@router.get("/users", response_model=list[AdminUserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@@ -97,21 +119,7 @@ async def list_users(
|
|||||||
marathons_count = await db.scalar(
|
marathons_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
response.append(AdminUserResponse(
|
response.append(build_admin_user_response(user, marathons_count))
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
))
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -130,21 +138,7 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
||||||
@@ -184,21 +178,7 @@ async def set_user_role(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||||
@@ -363,21 +343,7 @@ async def ban_user(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||||
@@ -418,21 +384,7 @@ async def unban_user(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=None,
|
|
||||||
banned_until=None,
|
|
||||||
ban_reason=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ Reset Password ============
|
# ============ Reset Password ============
|
||||||
@@ -478,21 +430,7 @@ async def reset_user_password(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ Force Finish Marathon ============
|
# ============ Force Finish Marathon ============
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class AdminUserResponse(BaseModel):
|
|||||||
banned_at: str | None = None
|
banned_at: str | None = None
|
||||||
banned_until: str | None = None # None = permanent
|
banned_until: str | None = None # None = permanent
|
||||||
ban_reason: str | None = None
|
ban_reason: str | None = None
|
||||||
|
# Notification settings
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
|
|||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
|
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@@ -195,6 +195,7 @@ export function AdminUsersPage() {
|
|||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Уведомления</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -202,13 +203,13 @@ export function AdminUsersPage() {
|
|||||||
<tbody className="divide-y divide-dark-600">
|
<tbody className="divide-y divide-dark-600">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center">
|
<td colSpan={9} className="px-4 py-8 text-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
|
||||||
Пользователи не найдены
|
Пользователи не найдены
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -236,6 +237,30 @@ export function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{user.telegram_id ? (
|
||||||
|
<div className="flex items-center gap-1" title={`События: ${user.notify_events ? 'вкл' : 'выкл'}, Споры: ${user.notify_disputes ? 'вкл' : 'выкл'}, Модерация: ${user.notify_moderation ? 'вкл' : 'выкл'}`}>
|
||||||
|
{user.notify_events && user.notify_disputes && user.notify_moderation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Все
|
||||||
|
</span>
|
||||||
|
) : !user.notify_events && !user.notify_disputes && !user.notify_moderation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||||
|
<BellOff className="w-3 h-3" />
|
||||||
|
Откл
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Частично
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{user.is_banned ? (
|
{user.is_banned ? (
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||||
|
|||||||
@@ -489,6 +489,10 @@ export interface AdminUser {
|
|||||||
banned_at: string | null
|
banned_at: string | null
|
||||||
banned_until: string | null // null = permanent ban
|
banned_until: string | null // null = permanent ban
|
||||||
ban_reason: string | null
|
ban_reason: string | null
|
||||||
|
// Notification settings
|
||||||
|
notify_events: boolean
|
||||||
|
notify_disputes: boolean
|
||||||
|
notify_moderation: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMarathon {
|
export interface AdminMarathon {
|
||||||
|
|||||||
Reference in New Issue
Block a user