"""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"๐Ÿ“ฅ Submission #{sub.id} ยท {left} waiting\n" f"๐Ÿ‘ค {who}{handle} ยท id{sub.user_id} ยท {approved} approved before\n" f"๐Ÿ“Ž {html.escape((sub.file_name or material.source_name or '?')[:70])} ยท {_mb(material.size)}" f"{f' ยท {material.pages} p' if material.pages else ''}\n" f"๐Ÿ“˜ {html.escape(_course_label(course))} ยท {material.kind}\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)