Spaces:
Running on Zero
Running on Zero
File size: 9,523 Bytes
4e64e70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """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)
|