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)