Dbu-central-bot / app /bot /handlers /admin_queue.py
cipher-attacks's picture
student contributions + QR
4e64e70
Raw History Blame Contribute Delete
9.52 kB
"""Admin review of student files: one card at a time, three taps, no per-file spam."""
import html
from typing import Optional
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot.handlers.admin import IsAdmin
from app.db.models import Submission
from app.db.repos import library as lib_repo
from app.db.repos import submissions as sub_repo
from app.db.repos import users as users_repo
from app.services import classifier as C
from app.services import contrib_rules as K
from app.services import contributions as contrib
from app.services import library as lib
router = Router()
router.message.filter(IsAdmin())
router.callback_query.filter(IsAdmin())
_skipped: dict[int, set[int]] = {} # admin_id -> submissions skipped in this review round
PAGE = 8
REASON_LABEL = {"duplicate": "♻️ Duplicate", "quality": "πŸ“‰ Low quality", "notallowed": "🚫 Not allowed"}
def _b(text: str, data: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data)
def _course_label(c) -> str:
return f"{c.code + ' ' if c.code else ''}{c.name}" if c else "❓ no course yet"
def _mb(size: Optional[int]) -> str:
return f"{size / 1048576:.1f} MB" if size else "?"
async def _card(session: AsyncSession, sub: Submission) -> Optional[tuple[str, InlineKeyboardMarkup]]:
material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None
if material is None:
return None
user = await users_repo.get(session, sub.user_id)
course = await lib_repo.get_course(session, material.course_id) if material.course_id else None
approved = await sub_repo.approved_count(session, sub.user_id)
left = await sub_repo.pending_total(session)
who = html.escape((user.full_name if user else "?") or "?")
handle = f" @{html.escape(user.username)}" if user and user.username else ""
text = (
f"πŸ“₯ <b>Submission #{sub.id}</b> Β· {left} waiting\n"
f"πŸ‘€ {who}{handle} Β· id{sub.user_id} Β· {approved} approved before\n"
f"πŸ“Ž <i>{html.escape((sub.file_name or material.source_name or '?')[:70])}</i> Β· {_mb(material.size)}"
f"{f' Β· {material.pages} p' if material.pages else ''}\n"
f"πŸ“˜ {html.escape(_course_label(course))} Β· <b>{material.kind}</b>\nβ€œ{html.escape(material.title)}”"
)
if sub.note:
text += f"\n⚠️ {html.escape(sub.note)}"
rows = [
[_b("βœ… Accept", f"qq:a:{sub.id}")],
[_b("✏️ Course", f"qq:c:{sub.id}"), _b("πŸ—‘ Reject", f"qq:r:{sub.id}")],
]
if sub.dup_of:
rows.append([_b(f"♻️ Replace #{sub.dup_of} with this", f"qq:p:{sub.id}")])
rows.append([_b("⏭ Skip", f"qq:k:{sub.id}")])
return text, InlineKeyboardMarkup(inline_keyboard=rows)
async def show_next(bot, session: AsyncSession, admin_id: int, chat_id: int) -> None:
"""Send the next waiting card (or say the queue is empty)."""
skip = _skipped.setdefault(admin_id, set())
for _ in range(25): # cards whose file vanished are cleaned up on the way
sub = await sub_repo.next_pending(session, skip)
if sub is None:
_skipped.pop(admin_id, None)
await bot.send_message(chat_id, "πŸŽ‰ Nothing is waiting for review.")
return
card = await _card(session, sub)
if card:
await bot.send_message(chat_id, card[0], reply_markup=card[1])
return
await contrib.reject(bot, session, sub, "quality") # file gone: close it silently
async def _finish(call: CallbackQuery, session: AsyncSession, text: str) -> None:
"""Replace the card with the result, then bring the next one."""
if call.message:
try:
await call.message.edit_text(text)
except Exception:
pass
await show_next(call.bot, session, call.from_user.id, call.message.chat.id)
async def _pending(call: CallbackQuery, session: AsyncSession, sid: int) -> Optional[Submission]:
sub = await sub_repo.get(session, sid)
if sub is None or sub.status != "pending":
await call.answer("Already handled.", show_alert=True)
return None
return sub
async def _rerender(call: CallbackQuery, session: AsyncSession, sub: Submission) -> None:
card = await _card(session, sub)
if card and call.message:
try:
await call.message.edit_text(card[0], reply_markup=card[1])
except Exception:
pass
@router.message(Command("queue"))
async def queue(message: Message, session: AsyncSession) -> None:
_skipped.pop(message.from_user.id, None)
await show_next(message.bot, session, message.from_user.id, message.chat.id)
@router.callback_query(F.data == "qq:start")
async def start(call: CallbackQuery, session: AsyncSession) -> None:
await call.answer()
_skipped.pop(call.from_user.id, None)
await show_next(call.bot, session, call.from_user.id, call.message.chat.id)
@router.callback_query(F.data.startswith("qq:a:"))
async def accept(call: CallbackQuery, session: AsyncSession) -> None:
sub = await _pending(call, session, int(call.data.split(":")[2]))
if not sub:
return
decision = await contrib.approve(call.bot, session, sub)
if not decision.ok:
await call.answer(decision.text, show_alert=True)
return
await call.answer()
await _finish(call, session, decision.text)
@router.callback_query(F.data.startswith("qq:p:"))
async def replace(call: CallbackQuery, session: AsyncSession) -> None:
sub = await _pending(call, session, int(call.data.split(":")[2]))
if not sub:
return
decision = await contrib.approve(call.bot, session, sub, replace_id=sub.dup_of)
if not decision.ok:
await call.answer(decision.text, show_alert=True)
return
await call.answer()
await _finish(call, session, decision.text + f"\n(replaced #{sub.dup_of})")
@router.callback_query(F.data.startswith("qq:r:"))
async def reject_menu(call: CallbackQuery, session: AsyncSession) -> None:
sub = await _pending(call, session, int(call.data.split(":")[2]))
if not sub:
return
rows = [[_b(REASON_LABEL[r], f"qq:x:{sub.id}:{r}")] for r in K.REJECT_REASONS]
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
await call.answer()
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
@router.callback_query(F.data.startswith("qq:x:"))
async def reject(call: CallbackQuery, session: AsyncSession) -> None:
_, _, sid, reason = call.data.split(":")
sub = await _pending(call, session, int(sid))
if not sub:
return
decision = await contrib.reject(call.bot, session, sub, reason)
await call.answer()
await _finish(call, session, decision.text)
@router.callback_query(F.data.startswith("qq:b:"))
async def back(call: CallbackQuery, session: AsyncSession) -> None:
sub = await _pending(call, session, int(call.data.split(":")[2]))
if sub:
await call.answer()
await _rerender(call, session, sub)
@router.callback_query(F.data.startswith("qq:k:"))
async def skip(call: CallbackQuery, session: AsyncSession) -> None:
sid = int(call.data.split(":")[2])
_skipped.setdefault(call.from_user.id, set()).add(sid)
await call.answer()
await _finish(call, session, f"⏭ Skipped #{sid} (use /queue to see it again).")
@router.callback_query(F.data.startswith("qq:c:"))
async def course_menu(call: CallbackQuery, session: AsyncSession) -> None:
sub = await _pending(call, session, int(call.data.split(":")[2]))
if not sub:
return
ranked = C.rank_courses(await lib.all_courses_cached(session), [(sub.file_name or "", "name")])
rows = [[_b(f"πŸ“˜ {_course_label(c)}"[:60], f"qq:s:{sub.id}:{c.id}")] for _, c in ranked[:5]]
rows.append([_b("πŸ“š All courses", f"qq:l:{sub.id}:0")])
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
await call.answer()
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
@router.callback_query(F.data.startswith("qq:l:"))
async def all_courses(call: CallbackQuery, session: AsyncSession) -> None:
_, _, sid, page = call.data.split(":")
sub = await _pending(call, session, int(sid))
if not sub:
return
page = int(page)
courses = await lib.all_courses_cached(session)
rows = [[_b(f"πŸ“˜ {_course_label(c)}"[:60], f"qq:s:{sub.id}:{c.id}")] for c in courses[page * PAGE : (page + 1) * PAGE]]
nav = []
if page > 0:
nav.append(_b("⬅️", f"qq:l:{sub.id}:{page - 1}"))
if (page + 1) * PAGE < len(courses):
nav.append(_b("➑️", f"qq:l:{sub.id}:{page + 1}"))
if nav:
rows.append(nav)
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
await call.answer()
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
@router.callback_query(F.data.startswith("qq:s:"))
async def set_course(call: CallbackQuery, session: AsyncSession) -> None:
_, _, sid, cid = call.data.split(":")
sub = await _pending(call, session, int(sid))
material = await lib_repo.get_material(session, sub.material_id) if sub else None
if not sub or not material:
return
material.course_id = int(cid)
await call.answer("Course set")
await _rerender(call, session, sub)