Spaces:
Running on Zero
Running on Zero
Commit ·
4e64e70
1
Parent(s): c3b9247
student contributions + QR
Browse files- app/bot/handlers/__init__.py +3 -1
- app/bot/handlers/admin.py +14 -0
- app/bot/handlers/admin_queue.py +232 -0
- app/bot/handlers/contribute.py +233 -0
- app/bot/handlers/menu.py +6 -0
- app/bot/keyboards/common.py +1 -1
- app/config.py +9 -0
- app/core/notify.py +4 -4
- app/db/migrate.py +2 -0
- app/db/models/__init__.py +2 -1
- app/db/models/material.py +1 -0
- app/db/models/submission.py +26 -0
- app/db/models/user.py +1 -0
- app/db/repos/library.py +9 -0
- app/db/repos/submissions.py +84 -0
- app/main.py +2 -1
- app/services/contrib_rules.py +71 -0
- app/services/contributions.py +173 -0
- app/services/library.py +2 -0
- app/services/qr.py +31 -0
- app/services/smart_ingest.py +22 -1
- app/services/users.py +1 -1
- app/tasks/jobs.py +18 -0
- locales/am.json +39 -2
- locales/en.json +39 -2
- tests/test_logic.py +50 -0
app/bot/handlers/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
-
from app.bot.handlers import admin, gate, groups, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
@@ -8,10 +8,12 @@ def build_router() -> Router:
|
|
| 8 |
# order matters: specific handlers first, plain-text search fallback LAST
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
|
|
|
| 11 |
router.include_router(groups.router)
|
| 12 |
router.include_router(start.router)
|
| 13 |
router.include_router(menu.router)
|
| 14 |
router.include_router(library.router)
|
|
|
|
| 15 |
router.include_router(inline.router)
|
| 16 |
router.include_router(search.router)
|
| 17 |
return router
|
|
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
+
from app.bot.handlers import admin, admin_queue, contribute, gate, groups, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
|
|
| 8 |
# order matters: specific handlers first, plain-text search fallback LAST
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
| 11 |
+
router.include_router(admin_queue.router)
|
| 12 |
router.include_router(groups.router)
|
| 13 |
router.include_router(start.router)
|
| 14 |
router.include_router(menu.router)
|
| 15 |
router.include_router(library.router)
|
| 16 |
+
router.include_router(contribute.router)
|
| 17 |
router.include_router(inline.router)
|
| 18 |
router.include_router(search.router)
|
| 19 |
return router
|
app/bot/handlers/admin.py
CHANGED
|
@@ -18,6 +18,7 @@ from app.core.seed_courses import FRESHMAN_COURSES
|
|
| 18 |
from app.db.models import User
|
| 19 |
from app.db.repos import library as repo
|
| 20 |
from app.db.repos import stats as stats_repo
|
|
|
|
| 21 |
from app.core import season as clock
|
| 22 |
from app.db.repos import users as users_repo
|
| 23 |
from app.services import growth
|
|
@@ -48,6 +49,7 @@ HELP = (
|
|
| 48 |
"/seedcourses : add the Year-1 course list (safe to run twice)\n"
|
| 49 |
"/courses : list courses with ids\n"
|
| 50 |
"/delcourse <id> [move_files_to_id] : delete a course (refuses if it still has files)\n"
|
|
|
|
| 51 |
"/growth : where new students come from + a funnel per source\n"
|
| 52 |
"/poster [tag] : printable A4 poster with a QR code (tag = where you will put it, e.g. dorm5)\n"
|
| 53 |
"/emptycourses : list courses with no files, with a button to delete them all\n"
|
|
@@ -235,6 +237,17 @@ async def poster(message: Message, command: CommandObject) -> None:
|
|
| 235 |
caption=f"🖨 Print it. The QR opens {link}\nSource tag: <b>{tag}</b>. See who came from it in /growth. "
|
| 236 |
"One poster per place works best: /poster dorm5, /poster cafeteria …",
|
| 237 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
|
| 240 |
@router.message(Command("emptycourses"))
|
|
@@ -324,6 +337,7 @@ async def stats(message: Message, session: AsyncSession) -> None:
|
|
| 324 |
f"Users: <b>{s['users']}</b> (passed gate {s['gate_passed']}, onboarded {s['onboarded']})\n"
|
| 325 |
f"Active today: <b>{s['active_today']}</b>\n"
|
| 326 |
f"Referrals: <b>{s['referrals']}</b>\n"
|
|
|
|
| 327 |
f"Courses: <b>{s['courses']}</b> · Files: <b>{s['materials']}</b> · Downloads: <b>{s['downloads']}</b>"
|
| 328 |
)
|
| 329 |
|
|
|
|
| 18 |
from app.db.models import User
|
| 19 |
from app.db.repos import library as repo
|
| 20 |
from app.db.repos import stats as stats_repo
|
| 21 |
+
from app.db.repos import submissions as sub_repo
|
| 22 |
from app.core import season as clock
|
| 23 |
from app.db.repos import users as users_repo
|
| 24 |
from app.services import growth
|
|
|
|
| 49 |
"/seedcourses : add the Year-1 course list (safe to run twice)\n"
|
| 50 |
"/courses : list courses with ids\n"
|
| 51 |
"/delcourse <id> [move_files_to_id] : delete a course (refuses if it still has files)\n"
|
| 52 |
+
"/queue : review files students sent (one card at a time: Accept / Course / Reject)\n"
|
| 53 |
"/growth : where new students come from + a funnel per source\n"
|
| 54 |
"/poster [tag] : printable A4 poster with a QR code (tag = where you will put it, e.g. dorm5)\n"
|
| 55 |
"/emptycourses : list courses with no files, with a button to delete them all\n"
|
|
|
|
| 237 |
caption=f"🖨 Print it. The QR opens {link}\nSource tag: <b>{tag}</b>. See who came from it in /growth. "
|
| 238 |
"One poster per place works best: /poster dorm5, /poster cafeteria …",
|
| 239 |
)
|
| 240 |
+
# the same QR as images, to drop into your own Amharic design (Canva, Word, Photoshop...)
|
| 241 |
+
from app.services.qr import qr_png
|
| 242 |
+
|
| 243 |
+
await message.answer_document(
|
| 244 |
+
BufferedInputFile(qr_png(link), filename=f"qr-{tag}.png"),
|
| 245 |
+
caption="🔳 QR on white (safest to print). Keep a clear white margin around it and do not shrink it below ~3 cm.",
|
| 246 |
+
)
|
| 247 |
+
await message.answer_document(
|
| 248 |
+
BufferedInputFile(qr_png(link, transparent=True), filename=f"qr-{tag}-transparent.png"),
|
| 249 |
+
caption="🔳 QR with a transparent background: place it on a LIGHT area of your design.",
|
| 250 |
+
)
|
| 251 |
|
| 252 |
|
| 253 |
@router.message(Command("emptycourses"))
|
|
|
|
| 337 |
f"Users: <b>{s['users']}</b> (passed gate {s['gate_passed']}, onboarded {s['onboarded']})\n"
|
| 338 |
f"Active today: <b>{s['active_today']}</b>\n"
|
| 339 |
f"Referrals: <b>{s['referrals']}</b>\n"
|
| 340 |
+
f"Student files waiting: <b>{await sub_repo.pending_total(session)}</b>\n"
|
| 341 |
f"Courses: <b>{s['courses']}</b> · Files: <b>{s['materials']}</b> · Downloads: <b>{s['downloads']}</b>"
|
| 342 |
)
|
| 343 |
|
app/bot/handlers/admin_queue.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin review of student files: one card at a time, three taps, no per-file spam."""
|
| 2 |
+
import html
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from aiogram import F, Router
|
| 6 |
+
from aiogram.filters import Command
|
| 7 |
+
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.bot.handlers.admin import IsAdmin
|
| 11 |
+
from app.db.models import Submission
|
| 12 |
+
from app.db.repos import library as lib_repo
|
| 13 |
+
from app.db.repos import submissions as sub_repo
|
| 14 |
+
from app.db.repos import users as users_repo
|
| 15 |
+
from app.services import classifier as C
|
| 16 |
+
from app.services import contrib_rules as K
|
| 17 |
+
from app.services import contributions as contrib
|
| 18 |
+
from app.services import library as lib
|
| 19 |
+
|
| 20 |
+
router = Router()
|
| 21 |
+
router.message.filter(IsAdmin())
|
| 22 |
+
router.callback_query.filter(IsAdmin())
|
| 23 |
+
_skipped: dict[int, set[int]] = {} # admin_id -> submissions skipped in this review round
|
| 24 |
+
PAGE = 8
|
| 25 |
+
REASON_LABEL = {"duplicate": "♻️ Duplicate", "quality": "📉 Low quality", "notallowed": "🚫 Not allowed"}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _b(text: str, data: str) -> InlineKeyboardButton:
|
| 29 |
+
return InlineKeyboardButton(text=text, callback_data=data)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _course_label(c) -> str:
|
| 33 |
+
return f"{c.code + ' ' if c.code else ''}{c.name}" if c else "❓ no course yet"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _mb(size: Optional[int]) -> str:
|
| 37 |
+
return f"{size / 1048576:.1f} MB" if size else "?"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def _card(session: AsyncSession, sub: Submission) -> Optional[tuple[str, InlineKeyboardMarkup]]:
|
| 41 |
+
material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None
|
| 42 |
+
if material is None:
|
| 43 |
+
return None
|
| 44 |
+
user = await users_repo.get(session, sub.user_id)
|
| 45 |
+
course = await lib_repo.get_course(session, material.course_id) if material.course_id else None
|
| 46 |
+
approved = await sub_repo.approved_count(session, sub.user_id)
|
| 47 |
+
left = await sub_repo.pending_total(session)
|
| 48 |
+
who = html.escape((user.full_name if user else "?") or "?")
|
| 49 |
+
handle = f" @{html.escape(user.username)}" if user and user.username else ""
|
| 50 |
+
text = (
|
| 51 |
+
f"📥 <b>Submission #{sub.id}</b> · {left} waiting\n"
|
| 52 |
+
f"👤 {who}{handle} · id{sub.user_id} · {approved} approved before\n"
|
| 53 |
+
f"📎 <i>{html.escape((sub.file_name or material.source_name or '?')[:70])}</i> · {_mb(material.size)}"
|
| 54 |
+
f"{f' · {material.pages} p' if material.pages else ''}\n"
|
| 55 |
+
f"📘 {html.escape(_course_label(course))} · <b>{material.kind}</b>\n“{html.escape(material.title)}”"
|
| 56 |
+
)
|
| 57 |
+
if sub.note:
|
| 58 |
+
text += f"\n⚠️ {html.escape(sub.note)}"
|
| 59 |
+
rows = [
|
| 60 |
+
[_b("✅ Accept", f"qq:a:{sub.id}")],
|
| 61 |
+
[_b("✏️ Course", f"qq:c:{sub.id}"), _b("🗑 Reject", f"qq:r:{sub.id}")],
|
| 62 |
+
]
|
| 63 |
+
if sub.dup_of:
|
| 64 |
+
rows.append([_b(f"♻️ Replace #{sub.dup_of} with this", f"qq:p:{sub.id}")])
|
| 65 |
+
rows.append([_b("⏭ Skip", f"qq:k:{sub.id}")])
|
| 66 |
+
return text, InlineKeyboardMarkup(inline_keyboard=rows)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def show_next(bot, session: AsyncSession, admin_id: int, chat_id: int) -> None:
|
| 70 |
+
"""Send the next waiting card (or say the queue is empty)."""
|
| 71 |
+
skip = _skipped.setdefault(admin_id, set())
|
| 72 |
+
for _ in range(25): # cards whose file vanished are cleaned up on the way
|
| 73 |
+
sub = await sub_repo.next_pending(session, skip)
|
| 74 |
+
if sub is None:
|
| 75 |
+
_skipped.pop(admin_id, None)
|
| 76 |
+
await bot.send_message(chat_id, "🎉 Nothing is waiting for review.")
|
| 77 |
+
return
|
| 78 |
+
card = await _card(session, sub)
|
| 79 |
+
if card:
|
| 80 |
+
await bot.send_message(chat_id, card[0], reply_markup=card[1])
|
| 81 |
+
return
|
| 82 |
+
await contrib.reject(bot, session, sub, "quality") # file gone: close it silently
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def _finish(call: CallbackQuery, session: AsyncSession, text: str) -> None:
|
| 86 |
+
"""Replace the card with the result, then bring the next one."""
|
| 87 |
+
if call.message:
|
| 88 |
+
try:
|
| 89 |
+
await call.message.edit_text(text)
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
await show_next(call.bot, session, call.from_user.id, call.message.chat.id)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def _pending(call: CallbackQuery, session: AsyncSession, sid: int) -> Optional[Submission]:
|
| 96 |
+
sub = await sub_repo.get(session, sid)
|
| 97 |
+
if sub is None or sub.status != "pending":
|
| 98 |
+
await call.answer("Already handled.", show_alert=True)
|
| 99 |
+
return None
|
| 100 |
+
return sub
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
async def _rerender(call: CallbackQuery, session: AsyncSession, sub: Submission) -> None:
|
| 104 |
+
card = await _card(session, sub)
|
| 105 |
+
if card and call.message:
|
| 106 |
+
try:
|
| 107 |
+
await call.message.edit_text(card[0], reply_markup=card[1])
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.message(Command("queue"))
|
| 113 |
+
async def queue(message: Message, session: AsyncSession) -> None:
|
| 114 |
+
_skipped.pop(message.from_user.id, None)
|
| 115 |
+
await show_next(message.bot, session, message.from_user.id, message.chat.id)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.callback_query(F.data == "qq:start")
|
| 119 |
+
async def start(call: CallbackQuery, session: AsyncSession) -> None:
|
| 120 |
+
await call.answer()
|
| 121 |
+
_skipped.pop(call.from_user.id, None)
|
| 122 |
+
await show_next(call.bot, session, call.from_user.id, call.message.chat.id)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.callback_query(F.data.startswith("qq:a:"))
|
| 126 |
+
async def accept(call: CallbackQuery, session: AsyncSession) -> None:
|
| 127 |
+
sub = await _pending(call, session, int(call.data.split(":")[2]))
|
| 128 |
+
if not sub:
|
| 129 |
+
return
|
| 130 |
+
decision = await contrib.approve(call.bot, session, sub)
|
| 131 |
+
if not decision.ok:
|
| 132 |
+
await call.answer(decision.text, show_alert=True)
|
| 133 |
+
return
|
| 134 |
+
await call.answer()
|
| 135 |
+
await _finish(call, session, decision.text)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.callback_query(F.data.startswith("qq:p:"))
|
| 139 |
+
async def replace(call: CallbackQuery, session: AsyncSession) -> None:
|
| 140 |
+
sub = await _pending(call, session, int(call.data.split(":")[2]))
|
| 141 |
+
if not sub:
|
| 142 |
+
return
|
| 143 |
+
decision = await contrib.approve(call.bot, session, sub, replace_id=sub.dup_of)
|
| 144 |
+
if not decision.ok:
|
| 145 |
+
await call.answer(decision.text, show_alert=True)
|
| 146 |
+
return
|
| 147 |
+
await call.answer()
|
| 148 |
+
await _finish(call, session, decision.text + f"\n(replaced #{sub.dup_of})")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.callback_query(F.data.startswith("qq:r:"))
|
| 152 |
+
async def reject_menu(call: CallbackQuery, session: AsyncSession) -> None:
|
| 153 |
+
sub = await _pending(call, session, int(call.data.split(":")[2]))
|
| 154 |
+
if not sub:
|
| 155 |
+
return
|
| 156 |
+
rows = [[_b(REASON_LABEL[r], f"qq:x:{sub.id}:{r}")] for r in K.REJECT_REASONS]
|
| 157 |
+
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
|
| 158 |
+
await call.answer()
|
| 159 |
+
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.callback_query(F.data.startswith("qq:x:"))
|
| 163 |
+
async def reject(call: CallbackQuery, session: AsyncSession) -> None:
|
| 164 |
+
_, _, sid, reason = call.data.split(":")
|
| 165 |
+
sub = await _pending(call, session, int(sid))
|
| 166 |
+
if not sub:
|
| 167 |
+
return
|
| 168 |
+
decision = await contrib.reject(call.bot, session, sub, reason)
|
| 169 |
+
await call.answer()
|
| 170 |
+
await _finish(call, session, decision.text)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@router.callback_query(F.data.startswith("qq:b:"))
|
| 174 |
+
async def back(call: CallbackQuery, session: AsyncSession) -> None:
|
| 175 |
+
sub = await _pending(call, session, int(call.data.split(":")[2]))
|
| 176 |
+
if sub:
|
| 177 |
+
await call.answer()
|
| 178 |
+
await _rerender(call, session, sub)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@router.callback_query(F.data.startswith("qq:k:"))
|
| 182 |
+
async def skip(call: CallbackQuery, session: AsyncSession) -> None:
|
| 183 |
+
sid = int(call.data.split(":")[2])
|
| 184 |
+
_skipped.setdefault(call.from_user.id, set()).add(sid)
|
| 185 |
+
await call.answer()
|
| 186 |
+
await _finish(call, session, f"⏭ Skipped #{sid} (use /queue to see it again).")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.callback_query(F.data.startswith("qq:c:"))
|
| 190 |
+
async def course_menu(call: CallbackQuery, session: AsyncSession) -> None:
|
| 191 |
+
sub = await _pending(call, session, int(call.data.split(":")[2]))
|
| 192 |
+
if not sub:
|
| 193 |
+
return
|
| 194 |
+
ranked = C.rank_courses(await lib.all_courses_cached(session), [(sub.file_name or "", "name")])
|
| 195 |
+
rows = [[_b(f"📘 {_course_label(c)}"[:60], f"qq:s:{sub.id}:{c.id}")] for _, c in ranked[:5]]
|
| 196 |
+
rows.append([_b("📚 All courses", f"qq:l:{sub.id}:0")])
|
| 197 |
+
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
|
| 198 |
+
await call.answer()
|
| 199 |
+
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@router.callback_query(F.data.startswith("qq:l:"))
|
| 203 |
+
async def all_courses(call: CallbackQuery, session: AsyncSession) -> None:
|
| 204 |
+
_, _, sid, page = call.data.split(":")
|
| 205 |
+
sub = await _pending(call, session, int(sid))
|
| 206 |
+
if not sub:
|
| 207 |
+
return
|
| 208 |
+
page = int(page)
|
| 209 |
+
courses = await lib.all_courses_cached(session)
|
| 210 |
+
rows = [[_b(f"📘 {_course_label(c)}"[:60], f"qq:s:{sub.id}:{c.id}")] for c in courses[page * PAGE : (page + 1) * PAGE]]
|
| 211 |
+
nav = []
|
| 212 |
+
if page > 0:
|
| 213 |
+
nav.append(_b("⬅️", f"qq:l:{sub.id}:{page - 1}"))
|
| 214 |
+
if (page + 1) * PAGE < len(courses):
|
| 215 |
+
nav.append(_b("➡️", f"qq:l:{sub.id}:{page + 1}"))
|
| 216 |
+
if nav:
|
| 217 |
+
rows.append(nav)
|
| 218 |
+
rows.append([_b("↩ Back", f"qq:b:{sub.id}")])
|
| 219 |
+
await call.answer()
|
| 220 |
+
await call.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.callback_query(F.data.startswith("qq:s:"))
|
| 224 |
+
async def set_course(call: CallbackQuery, session: AsyncSession) -> None:
|
| 225 |
+
_, _, sid, cid = call.data.split(":")
|
| 226 |
+
sub = await _pending(call, session, int(sid))
|
| 227 |
+
material = await lib_repo.get_material(session, sub.material_id) if sub else None
|
| 228 |
+
if not sub or not material:
|
| 229 |
+
return
|
| 230 |
+
material.course_id = int(cid)
|
| 231 |
+
await call.answer("Course set")
|
| 232 |
+
await _rerender(call, session, sub)
|
app/bot/handlers/contribute.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Students giving files: a clear panel, a short window to send files, instant feedback.
|
| 2 |
+
Nothing a student sends is visible to others until an admin approves it."""
|
| 3 |
+
import time
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from aiogram import F, Router
|
| 7 |
+
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.bot.keyboards import library as lib_kb
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.core.i18n import all_translations, t
|
| 13 |
+
from app.db.models import User
|
| 14 |
+
from app.db.repos import library as lib_repo
|
| 15 |
+
from app.db.repos import submissions as sub_repo
|
| 16 |
+
from app.services import contrib_rules as K
|
| 17 |
+
from app.services import contributions as contrib
|
| 18 |
+
from app.services import library as lib
|
| 19 |
+
from app.services.classifier import display_title
|
| 20 |
+
|
| 21 |
+
router = Router()
|
| 22 |
+
router.message.filter(F.chat.type == "private")
|
| 23 |
+
WINDOW = 600 # seconds the "send me files now" window stays open
|
| 24 |
+
_mode: dict[int, dict] = {} # user_id -> {"until": monotonic, "course_id": Optional[int]}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _btn(text: str, data: str) -> InlineKeyboardButton:
|
| 28 |
+
return InlineKeyboardButton(text=text, callback_data=data)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _open_window(user_id: int, course_id: Optional[int] = None) -> None:
|
| 32 |
+
_mode[user_id] = {"until": time.monotonic() + WINDOW, "course_id": course_id}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _window(user_id: int) -> Optional[dict]:
|
| 36 |
+
mode = _mode.get(user_id)
|
| 37 |
+
return mode if mode and mode["until"] > time.monotonic() else None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _reason_text(lang: str, reason: Optional[str]) -> str:
|
| 41 |
+
if not reason:
|
| 42 |
+
return ""
|
| 43 |
+
return t(lang, f"contrib_reason_{reason}", days=get_settings().contribution_min_days)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _badge_line(lang: str, approved: int) -> str:
|
| 47 |
+
tier = K.badge_for(approved)
|
| 48 |
+
return f"\n🏷 {t(lang, f'badge_{tier}')}" if tier else ""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def _panel(user: User, session: AsyncSession) -> tuple[str, InlineKeyboardMarkup]:
|
| 52 |
+
cfg, lang = get_settings(), user.lang
|
| 53 |
+
approved, pending, _ = await sub_repo.stats_for(session, user.id)
|
| 54 |
+
reason = await contrib.check_eligibility(session, user)
|
| 55 |
+
nxt = K.next_badge(approved)
|
| 56 |
+
hint = f"\n🎯 {t(lang, 'contrib_next_badge', n=nxt[0], badge=t(lang, f'badge_{nxt[1]}'))}" if nxt else ""
|
| 57 |
+
text = t(
|
| 58 |
+
lang, "contrib_panel", xp=cfg.contribution_xp, bonus=cfg.contribution_bonus_xp, cap=cfg.contribution_weekly_cap,
|
| 59 |
+
approved=approved, pending=pending, badge=_badge_line(lang, approved) + hint, reason=_reason_text(lang, reason),
|
| 60 |
+
)
|
| 61 |
+
credit = t(lang, "contrib_credit_on" if user.credit_name else "contrib_credit_off")
|
| 62 |
+
kb = InlineKeyboardMarkup(inline_keyboard=[
|
| 63 |
+
[_btn(t(lang, "contrib_btn_go"), "ct:go")],
|
| 64 |
+
[_btn(t(lang, "contrib_btn_wanted"), "ct:wanted")],
|
| 65 |
+
[_btn(credit, "ct:credit")],
|
| 66 |
+
])
|
| 67 |
+
return text, kb
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.message(F.text.in_(all_translations("btn_contribute")))
|
| 71 |
+
async def open_panel(message: Message, user: User, session: AsyncSession) -> None:
|
| 72 |
+
if not get_settings().contributions_enabled:
|
| 73 |
+
await message.answer(t(user.lang, "soon"))
|
| 74 |
+
return
|
| 75 |
+
text, kb = await _panel(user, session)
|
| 76 |
+
await message.answer(text, reply_markup=kb)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.callback_query(F.data == "ct:credit")
|
| 80 |
+
async def toggle_credit(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 81 |
+
user.credit_name = not user.credit_name
|
| 82 |
+
await call.answer(t(user.lang, "contrib_credit_saved_on" if user.credit_name else "contrib_credit_saved_off"))
|
| 83 |
+
text, kb = await _panel(user, session)
|
| 84 |
+
if call.message:
|
| 85 |
+
try:
|
| 86 |
+
await call.message.edit_text(text, reply_markup=kb)
|
| 87 |
+
except Exception:
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.callback_query(F.data == "ct:go")
|
| 92 |
+
async def go(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 93 |
+
reason = await contrib.check_eligibility(session, user)
|
| 94 |
+
if reason:
|
| 95 |
+
await call.answer(_reason_text(user.lang, reason).strip()[:190], show_alert=True)
|
| 96 |
+
return
|
| 97 |
+
_open_window(user.id)
|
| 98 |
+
await call.answer()
|
| 99 |
+
await call.message.answer(t(user.lang, "contrib_send_now"))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.callback_query(F.data == "ct:wanted")
|
| 103 |
+
async def wanted(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 104 |
+
items = await contrib.wanted_courses(session, user)
|
| 105 |
+
await call.answer()
|
| 106 |
+
if not items:
|
| 107 |
+
await call.message.answer(t(user.lang, "contrib_wanted_none"))
|
| 108 |
+
return
|
| 109 |
+
rows = [[_btn(f"📘 {lib_kb.course_label(c)} · {n}", f"ct:w:{c.id}")] for c, n in items]
|
| 110 |
+
await call.message.answer(t(user.lang, "contrib_wanted"), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@router.callback_query(F.data.startswith("ct:w:"))
|
| 114 |
+
async def wanted_pick(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 115 |
+
reason = await contrib.check_eligibility(session, user)
|
| 116 |
+
if reason:
|
| 117 |
+
await call.answer(_reason_text(user.lang, reason).strip()[:190], show_alert=True)
|
| 118 |
+
return
|
| 119 |
+
course = await lib_repo.get_course(session, int(call.data.split(":")[2]))
|
| 120 |
+
if not course:
|
| 121 |
+
await call.answer(t(user.lang, "file_missing"), show_alert=True)
|
| 122 |
+
return
|
| 123 |
+
_open_window(user.id, course.id)
|
| 124 |
+
await call.answer()
|
| 125 |
+
await call.message.answer(t(user.lang, "contrib_send_now_course", course=course.name))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------- receiving files
|
| 129 |
+
@router.message(F.chat.type == "private", F.document | F.photo | F.video | F.audio)
|
| 130 |
+
async def receive(message: Message, user: User, session: AsyncSession) -> None:
|
| 131 |
+
lang = user.lang
|
| 132 |
+
if not get_settings().contributions_enabled:
|
| 133 |
+
return
|
| 134 |
+
mode = _window(user.id)
|
| 135 |
+
if mode is None:
|
| 136 |
+
await message.answer(t(lang, "contrib_tap_first"))
|
| 137 |
+
return
|
| 138 |
+
reason = await contrib.check_eligibility(session, user)
|
| 139 |
+
if reason:
|
| 140 |
+
await message.answer(_reason_text(lang, reason).strip())
|
| 141 |
+
return
|
| 142 |
+
doc = message.document
|
| 143 |
+
if doc is None:
|
| 144 |
+
await message.answer(t(lang, "contrib_not_doc"))
|
| 145 |
+
return
|
| 146 |
+
problem = K.check_file(doc.file_name, doc.file_size)
|
| 147 |
+
if problem:
|
| 148 |
+
await message.answer(t(lang, "contrib_too_big" if problem == "size" else "contrib_not_doc"))
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
forced = await lib_repo.get_course(session, mode["course_id"]) if mode["course_id"] else None
|
| 152 |
+
result = await contrib.submit_file(
|
| 153 |
+
message.bot, session, user, file_id=doc.file_id, unique_id=doc.file_unique_id, file_name=doc.file_name,
|
| 154 |
+
size=doc.file_size, caption=message.caption, forced_course=forced,
|
| 155 |
+
)
|
| 156 |
+
_open_window(user.id, mode["course_id"]) # keep the window open for the next file
|
| 157 |
+
|
| 158 |
+
if result.kind == "duplicate":
|
| 159 |
+
title = display_title(result.duplicate_of.title) if result.duplicate_of else ""
|
| 160 |
+
await message.answer(t(lang, "contrib_dup", title=title))
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
m, sub = result.material, result.submission
|
| 164 |
+
title = display_title(m.title)
|
| 165 |
+
if result.kind == "need_course":
|
| 166 |
+
rows = [
|
| 167 |
+
[_btn(f"📘 {lib_kb.course_label(c)}", f"ct:c:{sub.id}:{c.id}")] for c in result.suggestions
|
| 168 |
+
]
|
| 169 |
+
rows.append([_btn(t(lang, "contrib_more_courses"), f"ct:more:{sub.id}:0")])
|
| 170 |
+
await message.answer(t(lang, "contrib_need_course", title=title), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 171 |
+
return
|
| 172 |
+
|
| 173 |
+
cfg = get_settings()
|
| 174 |
+
course = await lib_repo.get_course(session, m.course_id)
|
| 175 |
+
sparse = await lib_repo.ok_file_count(session, m.course_id) < K.SPARSE_COURSE_FILES
|
| 176 |
+
await message.answer(
|
| 177 |
+
t(
|
| 178 |
+
lang, "contrib_accepted", title=title, course=course.name if course else "", kind=t(lang, f"kind_{m.kind}"),
|
| 179 |
+
xp=cfg.contribution_xp, bonus=t(lang, "contrib_bonus", n=cfg.contribution_bonus_xp) if sparse else "",
|
| 180 |
+
note=t(lang, "contrib_near_note") if sub.dup_of else "",
|
| 181 |
+
),
|
| 182 |
+
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[_btn(t(lang, "contrib_change_course"), f"ct:more:{sub.id}:0")]]),
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ---------------------------------------------------------------- course picking for a submission
|
| 187 |
+
async def _own_pending(call: CallbackQuery, user: User, session: AsyncSession, sid: int):
|
| 188 |
+
sub = await sub_repo.get(session, sid)
|
| 189 |
+
if not sub or sub.user_id != user.id or sub.status != "pending":
|
| 190 |
+
await call.answer(t(user.lang, "file_missing"), show_alert=True)
|
| 191 |
+
return None, None
|
| 192 |
+
return sub, await lib_repo.get_material(session, sub.material_id)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.callback_query(F.data.startswith("ct:c:"))
|
| 196 |
+
async def set_course(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 197 |
+
_, _, sid, cid = call.data.split(":")
|
| 198 |
+
sub, material = await _own_pending(call, user, session, int(sid))
|
| 199 |
+
course = await lib_repo.get_course(session, int(cid))
|
| 200 |
+
if not sub or not material or not course:
|
| 201 |
+
return
|
| 202 |
+
material.course_id = course.id
|
| 203 |
+
await call.answer()
|
| 204 |
+
text = t(user.lang, "contrib_course_set", title=display_title(material.title), course=course.name)
|
| 205 |
+
try:
|
| 206 |
+
await call.message.edit_text(text)
|
| 207 |
+
except Exception:
|
| 208 |
+
await call.message.answer(text)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@router.callback_query(F.data.startswith("ct:more:"))
|
| 212 |
+
async def more_courses(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 213 |
+
_, _, sid, page = call.data.split(":")
|
| 214 |
+
sub, material = await _own_pending(call, user, session, int(sid))
|
| 215 |
+
if not sub:
|
| 216 |
+
return
|
| 217 |
+
page = int(page)
|
| 218 |
+
items = await lib_repo.list_courses(session, user.stream, user.year, page * lib.PAGE_SIZE, lib.PAGE_SIZE)
|
| 219 |
+
total = await lib_repo.count_courses(session, user.stream, user.year)
|
| 220 |
+
rows = [[_btn(f"📘 {lib_kb.course_label(c)}", f"ct:c:{sub.id}:{c.id}")] for c in items]
|
| 221 |
+
nav = []
|
| 222 |
+
if page > 0:
|
| 223 |
+
nav.append(_btn(t(user.lang, "btn_prev"), f"ct:more:{sub.id}:{page - 1}"))
|
| 224 |
+
if (page + 1) * lib.PAGE_SIZE < total:
|
| 225 |
+
nav.append(_btn(t(user.lang, "btn_next"), f"ct:more:{sub.id}:{page + 1}"))
|
| 226 |
+
if nav:
|
| 227 |
+
rows.append(nav)
|
| 228 |
+
await call.answer()
|
| 229 |
+
text = t(user.lang, "contrib_need_course", title=display_title(material.title) if material else "")
|
| 230 |
+
try:
|
| 231 |
+
await call.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
| 232 |
+
except Exception:
|
| 233 |
+
pass
|
app/bot/handlers/menu.py
CHANGED
|
@@ -8,7 +8,9 @@ from app.config import get_settings
|
|
| 8 |
from app.core import season
|
| 9 |
from app.core.i18n import all_translations, t
|
| 10 |
from app.db.models import User
|
|
|
|
| 11 |
from app.db.repos import users as users_repo
|
|
|
|
| 12 |
from app.services import growth
|
| 13 |
from app.services import referrals as referral_service
|
| 14 |
from app.services import xp_rules as R
|
|
@@ -50,6 +52,9 @@ async def profile(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 50 |
level, needed = R.level_for(user.points or 0)
|
| 51 |
count, qualified = await referral_service.stats(session, user)
|
| 52 |
rank = await users_repo.week_rank(session, user, week)
|
|
|
|
|
|
|
|
|
|
| 53 |
await message.answer(
|
| 54 |
t(
|
| 55 |
lang,
|
|
@@ -67,6 +72,7 @@ async def profile(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 67 |
best=user.best_streak,
|
| 68 |
refs=count,
|
| 69 |
refs_q=qualified,
|
|
|
|
| 70 |
)
|
| 71 |
)
|
| 72 |
|
|
|
|
| 8 |
from app.core import season
|
| 9 |
from app.core.i18n import all_translations, t
|
| 10 |
from app.db.models import User
|
| 11 |
+
from app.db.repos import submissions as sub_repo
|
| 12 |
from app.db.repos import users as users_repo
|
| 13 |
+
from app.services import contrib_rules as K
|
| 14 |
from app.services import growth
|
| 15 |
from app.services import referrals as referral_service
|
| 16 |
from app.services import xp_rules as R
|
|
|
|
| 52 |
level, needed = R.level_for(user.points or 0)
|
| 53 |
count, qualified = await referral_service.stats(session, user)
|
| 54 |
rank = await users_repo.week_rank(session, user, week)
|
| 55 |
+
approved = await sub_repo.approved_count(session, user.id)
|
| 56 |
+
tier = K.badge_for(approved)
|
| 57 |
+
badge = f"\n🏷 {t(lang, f'badge_{tier}')} ({approved})" if tier else ""
|
| 58 |
await message.answer(
|
| 59 |
t(
|
| 60 |
lang,
|
|
|
|
| 72 |
best=user.best_streak,
|
| 73 |
refs=count,
|
| 74 |
refs_q=qualified,
|
| 75 |
+
badge=badge,
|
| 76 |
)
|
| 77 |
)
|
| 78 |
|
app/bot/keyboards/common.py
CHANGED
|
@@ -50,7 +50,7 @@ def main_menu(lang: str) -> ReplyKeyboardMarkup:
|
|
| 50 |
|
| 51 |
return ReplyKeyboardMarkup(
|
| 52 |
keyboard=[
|
| 53 |
-
[b("btn_library")],
|
| 54 |
[b("btn_profile"), b("btn_top")],
|
| 55 |
[b("btn_gpa"), b("btn_invite")],
|
| 56 |
[b("btn_lang")],
|
|
|
|
| 50 |
|
| 51 |
return ReplyKeyboardMarkup(
|
| 52 |
keyboard=[
|
| 53 |
+
[b("btn_library"), b("btn_contribute")],
|
| 54 |
[b("btn_profile"), b("btn_top")],
|
| 55 |
[b("btn_gpa"), b("btn_invite")],
|
| 56 |
[b("btn_lang")],
|
app/config.py
CHANGED
|
@@ -32,6 +32,15 @@ class Settings(BaseSettings):
|
|
| 32 |
def watermark_link(self) -> str:
|
| 33 |
return self.watermark_url or f"https://t.me/{self.gate_channel.lstrip('@')}"
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
# --- weekly season / prizes (money is OFF until you switch it on) ---
|
| 36 |
prizes_enabled: bool = False
|
| 37 |
prize_top: str = "50,30,20" # birr airtime card for rank 1,2,3 (comma separated)
|
|
|
|
| 32 |
def watermark_link(self) -> str:
|
| 33 |
return self.watermark_url or f"https://t.me/{self.gate_channel.lstrip('@')}"
|
| 34 |
|
| 35 |
+
# --- student file contributions ---
|
| 36 |
+
contributions_enabled: bool = True
|
| 37 |
+
contribution_xp: int = 20
|
| 38 |
+
contribution_bonus_xp: int = 5 # extra when the course is short of material
|
| 39 |
+
contribution_weekly_cap: int = 5 # rewarded files per student per week
|
| 40 |
+
contribution_min_days: int = 2 # active days before a student may submit
|
| 41 |
+
contribution_pending_limit: int = 5
|
| 42 |
+
contribution_daily_limit: int = 8
|
| 43 |
+
|
| 44 |
# --- weekly season / prizes (money is OFF until you switch it on) ---
|
| 45 |
prizes_enabled: bool = False
|
| 46 |
prize_top: str = "50,30,20" # birr airtime card for rank 1,2,3 (comma separated)
|
app/core/notify.py
CHANGED
|
@@ -7,15 +7,15 @@ from app.config import get_settings
|
|
| 7 |
log = logging.getLogger(__name__)
|
| 8 |
|
| 9 |
|
| 10 |
-
async def safe_send(bot: Bot, chat_id: int, text: str) -> bool:
|
| 11 |
try:
|
| 12 |
-
await bot.send_message(chat_id, text)
|
| 13 |
return True
|
| 14 |
except Exception as exc:
|
| 15 |
log.info("Could not message %s: %r", chat_id, exc)
|
| 16 |
return False
|
| 17 |
|
| 18 |
|
| 19 |
-
async def notify_admins(bot: Bot, text: str) -> None:
|
| 20 |
for admin_id in get_settings().admin_id_set:
|
| 21 |
-
await safe_send(bot, admin_id, text[:4000])
|
|
|
|
| 7 |
log = logging.getLogger(__name__)
|
| 8 |
|
| 9 |
|
| 10 |
+
async def safe_send(bot: Bot, chat_id: int, text: str, reply_markup=None) -> bool:
|
| 11 |
try:
|
| 12 |
+
await bot.send_message(chat_id, text, reply_markup=reply_markup)
|
| 13 |
return True
|
| 14 |
except Exception as exc:
|
| 15 |
log.info("Could not message %s: %r", chat_id, exc)
|
| 16 |
return False
|
| 17 |
|
| 18 |
|
| 19 |
+
async def notify_admins(bot: Bot, text: str, reply_markup=None) -> None:
|
| 20 |
for admin_id in get_settings().admin_id_set:
|
| 21 |
+
await safe_send(bot, admin_id, text[:4000], reply_markup=reply_markup)
|
app/db/migrate.py
CHANGED
|
@@ -39,6 +39,8 @@ POSTGRES_UPGRADES = [
|
|
| 39 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS source VARCHAR(24)",
|
| 40 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_link VARCHAR(16)",
|
| 41 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS share_prompt_day VARCHAR(10)",
|
|
|
|
|
|
|
| 42 |
"ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
|
| 43 |
]
|
| 44 |
|
|
|
|
| 39 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS source VARCHAR(24)",
|
| 40 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_link VARCHAR(16)",
|
| 41 |
"ALTER TABLE users ADD COLUMN IF NOT EXISTS share_prompt_day VARCHAR(10)",
|
| 42 |
+
"ALTER TABLE users ADD COLUMN IF NOT EXISTS credit_name BOOLEAN NOT NULL DEFAULT FALSE",
|
| 43 |
+
"ALTER TABLE materials ADD COLUMN IF NOT EXISTS credit VARCHAR(40)",
|
| 44 |
"ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
|
| 45 |
]
|
| 46 |
|
app/db/models/__init__.py
CHANGED
|
@@ -3,7 +3,8 @@ from app.db.models.group import BotGroup
|
|
| 3 |
from app.db.models.material import Material
|
| 4 |
from app.db.models.pending import PendingMaterial
|
| 5 |
from app.db.models.referral import Referral
|
|
|
|
| 6 |
from app.db.models.user import User
|
| 7 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 8 |
|
| 9 |
-
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult", "BotGroup"]
|
|
|
|
| 3 |
from app.db.models.material import Material
|
| 4 |
from app.db.models.pending import PendingMaterial
|
| 5 |
from app.db.models.referral import Referral
|
| 6 |
+
from app.db.models.submission import Submission
|
| 7 |
from app.db.models.user import User
|
| 8 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 9 |
|
| 10 |
+
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult", "BotGroup", "Submission"]
|
app/db/models/material.py
CHANGED
|
@@ -31,6 +31,7 @@ class Material(Base):
|
|
| 31 |
size: Mapped[Optional[int]] = mapped_column(Integer)
|
| 32 |
source_name: Mapped[Optional[str]] = mapped_column(String(256)) # original file name
|
| 33 |
status: Mapped[str] = mapped_column(String(8), default="ok", server_default="ok") # ok | review
|
|
|
|
| 34 |
downloads: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 35 |
uploader_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
| 36 |
library_chat_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
|
|
|
| 31 |
size: Mapped[Optional[int]] = mapped_column(Integer)
|
| 32 |
source_name: Mapped[Optional[str]] = mapped_column(String(256)) # original file name
|
| 33 |
status: Mapped[str] = mapped_column(String(8), default="ok", server_default="ok") # ok | review
|
| 34 |
+
credit: Mapped[Optional[str]] = mapped_column(String(40)) # contributor name, if they chose to be credited
|
| 35 |
downloads: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 36 |
uploader_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
| 37 |
library_chat_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
app/db/models/submission.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Submission(Base):
|
| 11 |
+
"""A file a student offered. The file itself is a Material with status 'pending' until approved."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "submissions"
|
| 14 |
+
|
| 15 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 16 |
+
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 17 |
+
material_id: Mapped[Optional[int]] = mapped_column(Integer, index=True)
|
| 18 |
+
file_name: Mapped[Optional[str]] = mapped_column(String(256))
|
| 19 |
+
status: Mapped[str] = mapped_column(String(10), default="pending", index=True) # pending|approved|rejected
|
| 20 |
+
reason: Mapped[Optional[str]] = mapped_column(String(24)) # rejection reason code
|
| 21 |
+
note: Mapped[Optional[str]] = mapped_column(String(300)) # flags shown to the admin
|
| 22 |
+
dup_of: Mapped[Optional[int]] = mapped_column(Integer) # existing material this one resembles
|
| 23 |
+
xp: Mapped[int] = mapped_column(Integer, default=0)
|
| 24 |
+
notified: Mapped[bool] = mapped_column(Boolean, default=False) # admin already told about it
|
| 25 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 26 |
+
reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
app/db/models/user.py
CHANGED
|
@@ -31,5 +31,6 @@ class User(Base):
|
|
| 31 |
source: Mapped[Optional[str]] = mapped_column(String(24)) # where they came from (poster, inline, referral...)
|
| 32 |
pending_link: Mapped[Optional[str]] = mapped_column(String(16)) # deep link to open after joining/onboarding
|
| 33 |
share_prompt_day: Mapped[Optional[str]] = mapped_column(String(10)) # last day we suggested sharing
|
|
|
|
| 34 |
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 35 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
| 31 |
source: Mapped[Optional[str]] = mapped_column(String(24)) # where they came from (poster, inline, referral...)
|
| 32 |
pending_link: Mapped[Optional[str]] = mapped_column(String(16)) # deep link to open after joining/onboarding
|
| 33 |
share_prompt_day: Mapped[Optional[str]] = mapped_column(String(10)) # last day we suggested sharing
|
| 34 |
+
credit_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # name on files they give
|
| 35 |
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 36 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/repos/library.py
CHANGED
|
@@ -277,6 +277,15 @@ async def unstamped_pdfs(session: AsyncSession, limit: int, max_size: int) -> li
|
|
| 277 |
return list(res.scalars())
|
| 278 |
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
async def count_course_files(session: AsyncSession, course_id: int) -> int:
|
| 281 |
res = await session.execute(select(func.count()).select_from(Material).where(Material.course_id == course_id))
|
| 282 |
return int(res.scalar_one())
|
|
|
|
| 277 |
return list(res.scalars())
|
| 278 |
|
| 279 |
|
| 280 |
+
async def ok_file_count(session: AsyncSession, course_id: int) -> int:
|
| 281 |
+
res = await session.execute(
|
| 282 |
+
select(func.count()).select_from(Material).where(
|
| 283 |
+
Material.course_id == course_id, Material.file_id.is_not(None), Material.status == "ok"
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
return int(res.scalar_one())
|
| 287 |
+
|
| 288 |
+
|
| 289 |
async def count_course_files(session: AsyncSession, course_id: int) -> int:
|
| 290 |
res = await session.execute(select(func.count()).select_from(Material).where(Material.course_id == course_id))
|
| 291 |
return int(res.scalar_one())
|
app/db/repos/submissions.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import func, select, update
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
+
from app.db.models import Submission
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def create(
|
| 11 |
+
session: AsyncSession, *, user_id: int, material_id: int, file_name: Optional[str],
|
| 12 |
+
note: Optional[str], dup_of: Optional[int],
|
| 13 |
+
) -> Submission:
|
| 14 |
+
sub = Submission(
|
| 15 |
+
user_id=user_id, material_id=material_id, file_name=(file_name or "")[:250] or None,
|
| 16 |
+
note=(note or None) and note[:290], dup_of=dup_of,
|
| 17 |
+
)
|
| 18 |
+
session.add(sub)
|
| 19 |
+
await session.flush()
|
| 20 |
+
return sub
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def get(session: AsyncSession, submission_id: int) -> Optional[Submission]:
|
| 24 |
+
return await session.get(Submission, submission_id)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def _count(session: AsyncSession, *conditions) -> int:
|
| 28 |
+
res = await session.execute(select(func.count()).select_from(Submission).where(*conditions))
|
| 29 |
+
return int(res.scalar_one())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def pending_count(session: AsyncSession, user_id: int) -> int:
|
| 33 |
+
return await _count(session, Submission.user_id == user_id, Submission.status == "pending")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def count_since(session: AsyncSession, user_id: int, since: datetime) -> int:
|
| 37 |
+
return await _count(session, Submission.user_id == user_id, Submission.created_at >= since)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def approved_count(session: AsyncSession, user_id: int) -> int:
|
| 41 |
+
return await _count(session, Submission.user_id == user_id, Submission.status == "approved")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def pending_total(session: AsyncSession) -> int:
|
| 45 |
+
return await _count(session, Submission.status == "pending")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def rejected_times(session: AsyncSession, user_id: int, since: datetime) -> list[datetime]:
|
| 49 |
+
res = await session.execute(
|
| 50 |
+
select(Submission.reviewed_at).where(
|
| 51 |
+
Submission.user_id == user_id, Submission.status == "rejected", Submission.reviewed_at >= since
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
return [d for d in res.scalars() if d is not None]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def next_pending(session: AsyncSession, skip: set[int]) -> Optional[Submission]:
|
| 58 |
+
stmt = select(Submission).where(Submission.status == "pending").order_by(Submission.id).limit(1)
|
| 59 |
+
if skip:
|
| 60 |
+
stmt = stmt.where(Submission.id.not_in(skip))
|
| 61 |
+
return (await session.execute(stmt)).scalar_one_or_none()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def unnotified(session: AsyncSession) -> list[Submission]:
|
| 65 |
+
res = await session.execute(
|
| 66 |
+
select(Submission).where(Submission.status == "pending", Submission.notified.is_(False)).order_by(Submission.id)
|
| 67 |
+
)
|
| 68 |
+
return list(res.scalars())
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def mark_notified(session: AsyncSession, ids: list[int]) -> None:
|
| 72 |
+
if ids:
|
| 73 |
+
await session.execute(
|
| 74 |
+
update(Submission).where(Submission.id.in_(ids)).values(notified=True).execution_options(synchronize_session=False)
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def stats_for(session: AsyncSession, user_id: int) -> tuple[int, int, int]:
|
| 79 |
+
"""(approved, pending, rejected)"""
|
| 80 |
+
return (
|
| 81 |
+
await _count(session, Submission.user_id == user_id, Submission.status == "approved"),
|
| 82 |
+
await _count(session, Submission.user_id == user_id, Submission.status == "pending"),
|
| 83 |
+
await _count(session, Submission.user_id == user_id, Submission.status == "rejected"),
|
| 84 |
+
)
|
app/main.py
CHANGED
|
@@ -9,7 +9,7 @@ from app.bot.loader import build_dispatcher
|
|
| 9 |
from app.config import get_settings
|
| 10 |
from app.core.logging import setup_logging
|
| 11 |
from app.db.session import init_db, keepalive, warm_up
|
| 12 |
-
from app.tasks.jobs import close_previous_week, periodic, qualify_referrals
|
| 13 |
|
| 14 |
log = logging.getLogger(__name__)
|
| 15 |
|
|
@@ -27,6 +27,7 @@ async def run_bot_forever() -> None:
|
|
| 27 |
asyncio.create_task(keepalive()),
|
| 28 |
asyncio.create_task(periodic("referrals", 1800, qualify_referrals, bot)),
|
| 29 |
asyncio.create_task(periodic("season", 600, close_previous_week, bot)),
|
|
|
|
| 30 |
]
|
| 31 |
try:
|
| 32 |
while True:
|
|
|
|
| 9 |
from app.config import get_settings
|
| 10 |
from app.core.logging import setup_logging
|
| 11 |
from app.db.session import init_db, keepalive, warm_up
|
| 12 |
+
from app.tasks.jobs import close_previous_week, notify_pending_submissions, periodic, qualify_referrals
|
| 13 |
|
| 14 |
log = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
| 27 |
asyncio.create_task(keepalive()),
|
| 28 |
asyncio.create_task(periodic("referrals", 1800, qualify_referrals, bot)),
|
| 29 |
asyncio.create_task(periodic("season", 600, close_previous_week, bot)),
|
| 30 |
+
asyncio.create_task(periodic("submissions", 120, notify_pending_submissions, bot, first_delay=90)),
|
| 31 |
]
|
| 32 |
try:
|
| 33 |
while True:
|
app/services/contrib_rules.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rules for student contributions. Pure, so every rule is unit-tested."""
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from typing import Optional, Sequence
|
| 5 |
+
|
| 6 |
+
ALLOWED_EXT = (".pdf", ".pptx", ".docx")
|
| 7 |
+
MAX_BYTES = 20 * 1024 * 1024 # the bot can only read files up to 20 MB (needed for duplicate checks + stamping)
|
| 8 |
+
BLOCK_AFTER_REJECTIONS = 3
|
| 9 |
+
REJECTION_WINDOW_DAYS = 14
|
| 10 |
+
BLOCK_DAYS = 7
|
| 11 |
+
SPARSE_COURSE_FILES = 3 # a course with fewer files than this counts as "wanted"
|
| 12 |
+
BADGES = ((15, "gold"), (5, "silver"), (1, "bronze")) # approved files needed -> badge
|
| 13 |
+
REJECT_REASONS = ("duplicate", "quality", "notallowed")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def check_file(file_name: Optional[str], size: Optional[int]) -> Optional[str]:
|
| 17 |
+
"""None = fine, else a reason code ('ext' | 'size')."""
|
| 18 |
+
if os.path.splitext(file_name or "")[1].lower() not in ALLOWED_EXT:
|
| 19 |
+
return "ext"
|
| 20 |
+
if size is None or size > MAX_BYTES:
|
| 21 |
+
return "size"
|
| 22 |
+
return None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def blocked_until(rejected_at: Sequence[datetime], now: datetime) -> Optional[datetime]:
|
| 26 |
+
"""3 rejections inside 14 days -> no new submissions for 7 days after the last one."""
|
| 27 |
+
recent = sorted(d for d in rejected_at if d >= now - timedelta(days=REJECTION_WINDOW_DAYS))
|
| 28 |
+
if len(recent) < BLOCK_AFTER_REJECTIONS:
|
| 29 |
+
return None
|
| 30 |
+
until = recent[-1] + timedelta(days=BLOCK_DAYS)
|
| 31 |
+
return until if until > now else None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def eligibility(
|
| 35 |
+
*, active_days: int, min_days: int, pending: int, pending_limit: int, today: int, daily_limit: int,
|
| 36 |
+
blocked: Optional[datetime],
|
| 37 |
+
) -> Optional[str]:
|
| 38 |
+
"""None = may submit, else a reason code."""
|
| 39 |
+
if active_days < min_days:
|
| 40 |
+
return "new"
|
| 41 |
+
if blocked is not None:
|
| 42 |
+
return "blocked"
|
| 43 |
+
if pending >= pending_limit:
|
| 44 |
+
return "pending"
|
| 45 |
+
if today >= daily_limit:
|
| 46 |
+
return "daily"
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def badge_for(approved: int) -> Optional[str]:
|
| 51 |
+
for needed, tier in BADGES:
|
| 52 |
+
if approved >= needed:
|
| 53 |
+
return tier
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def next_badge(approved: int) -> Optional[tuple[int, str]]:
|
| 58 |
+
"""(files still needed, tier) for the next badge, or None at the top."""
|
| 59 |
+
for needed, tier in reversed(BADGES):
|
| 60 |
+
if approved < needed:
|
| 61 |
+
return needed - approved, tier
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def reward(
|
| 66 |
+
*, rewarded_this_week: int, course_files_before: int, xp: int, bonus: int, weekly_cap: int
|
| 67 |
+
) -> tuple[int, int]:
|
| 68 |
+
"""(XP, bonus XP). Nothing once the weekly cap is used; the bonus is for courses that are short of material."""
|
| 69 |
+
if rewarded_this_week >= weekly_cap:
|
| 70 |
+
return 0, 0
|
| 71 |
+
return xp, (bonus if course_files_before < SPARSE_COURSE_FILES else 0)
|
app/services/contributions.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Student contributions: submit -> admin review -> approve (stamp, publish, reward) or reject (explain)."""
|
| 2 |
+
import logging
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from aiogram import Bot
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
from app.core import season
|
| 12 |
+
from app.core.i18n import t
|
| 13 |
+
from app.core.notify import safe_send
|
| 14 |
+
from app.db.models import Material, Submission, User
|
| 15 |
+
from app.db.repos import library as lib_repo
|
| 16 |
+
from app.db.repos import submissions as sub_repo
|
| 17 |
+
from app.db.repos import users as users_repo
|
| 18 |
+
from app.db.repos import xp as xp_repo
|
| 19 |
+
from app.services import contrib_rules as K
|
| 20 |
+
from app.services import library as library_service
|
| 21 |
+
from app.services import smart_ingest
|
| 22 |
+
from app.services import xp as xp_service
|
| 23 |
+
|
| 24 |
+
log = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ------------------------------------------------------------------ student side
|
| 28 |
+
async def check_eligibility(session: AsyncSession, user: User) -> Optional[str]:
|
| 29 |
+
"""None = may submit now, else a reason code (new | blocked | pending | daily)."""
|
| 30 |
+
cfg = get_settings()
|
| 31 |
+
now = datetime.now(timezone.utc)
|
| 32 |
+
rejected = await sub_repo.rejected_times(session, user.id, now - timedelta(days=K.REJECTION_WINDOW_DAYS))
|
| 33 |
+
return K.eligibility(
|
| 34 |
+
active_days=user.active_days or 0,
|
| 35 |
+
min_days=cfg.contribution_min_days,
|
| 36 |
+
pending=await sub_repo.pending_count(session, user.id),
|
| 37 |
+
pending_limit=cfg.contribution_pending_limit,
|
| 38 |
+
today=await sub_repo.count_since(session, user.id, now - timedelta(hours=24)),
|
| 39 |
+
daily_limit=cfg.contribution_daily_limit,
|
| 40 |
+
blocked=K.blocked_until(rejected, now),
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class SubmitResult:
|
| 46 |
+
kind: str # duplicate | accepted | need_course
|
| 47 |
+
submission: Optional[Submission] = None
|
| 48 |
+
material: Optional[Material] = None
|
| 49 |
+
outcome: Optional[smart_ingest.Outcome] = None
|
| 50 |
+
duplicate_of: Optional[Material] = None
|
| 51 |
+
suggestions: list = field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def submit_file(
|
| 55 |
+
bot: Bot, session: AsyncSession, user: User, *, file_id: str, unique_id: str,
|
| 56 |
+
file_name: Optional[str], size: Optional[int], caption: Optional[str], forced_course=None,
|
| 57 |
+
) -> SubmitResult:
|
| 58 |
+
out = await smart_ingest.ingest_file(
|
| 59 |
+
bot, session, uploader_id=user.id, file_type="document", file_id=file_id, unique_id=unique_id,
|
| 60 |
+
file_name=file_name, size=size, caption=caption, forced_course=forced_course,
|
| 61 |
+
backup_channel=0, message=None, stamp=False, # stamped only when an admin approves it
|
| 62 |
+
)
|
| 63 |
+
if out.status == "duplicate":
|
| 64 |
+
return SubmitResult("duplicate", duplicate_of=out.duplicate_of, outcome=out)
|
| 65 |
+
material = out.material
|
| 66 |
+
material.status = "pending" # invisible to students until approved
|
| 67 |
+
flags = list(out.notes)
|
| 68 |
+
dup_of = None
|
| 69 |
+
if out.status == "near_duplicate" and out.duplicate_of is not None:
|
| 70 |
+
dup_of = out.duplicate_of.id
|
| 71 |
+
flags.append(f"near-duplicate of #{dup_of} “{out.duplicate_of.title[:40]}”")
|
| 72 |
+
if material.course_id is None:
|
| 73 |
+
flags.append("course unknown")
|
| 74 |
+
sub = await sub_repo.create(
|
| 75 |
+
session, user_id=user.id, material_id=material.id, file_name=file_name, note="; ".join(flags), dup_of=dup_of
|
| 76 |
+
)
|
| 77 |
+
kind = "need_course" if material.course_id is None else "accepted"
|
| 78 |
+
return SubmitResult(kind, submission=sub, material=material, outcome=out, suggestions=out.suggestions)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def wanted_courses(session: AsyncSession, user: User, limit: int = 8) -> list[tuple[object, int]]:
|
| 82 |
+
"""Courses (for this student's stream/year) with the fewest files first, only those short of material."""
|
| 83 |
+
counts = await lib_repo.file_counts(session)
|
| 84 |
+
courses = await lib_repo.list_courses(session, user.stream, user.year, 0, 200, only_with_files=False)
|
| 85 |
+
short = [(c, counts.get(c.id, 0)) for c in courses if counts.get(c.id, 0) < K.SPARSE_COURSE_FILES]
|
| 86 |
+
short.sort(key=lambda item: (item[1], item[0].id))
|
| 87 |
+
return short[:limit]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------ admin side
|
| 91 |
+
@dataclass
|
| 92 |
+
class Decision:
|
| 93 |
+
ok: bool
|
| 94 |
+
text: str
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def approve(
|
| 98 |
+
bot: Bot, session: AsyncSession, sub: Submission, *, replace_id: Optional[int] = None
|
| 99 |
+
) -> Decision:
|
| 100 |
+
cfg = get_settings()
|
| 101 |
+
material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None
|
| 102 |
+
if material is None:
|
| 103 |
+
sub.status, sub.reviewed_at, sub.reason = "rejected", datetime.now(timezone.utc), "gone"
|
| 104 |
+
return Decision(False, "That file no longer exists.")
|
| 105 |
+
if material.course_id is None:
|
| 106 |
+
return Decision(False, "Pick a course first.")
|
| 107 |
+
|
| 108 |
+
submitter = await users_repo.get(session, sub.user_id)
|
| 109 |
+
files_before = await lib_repo.ok_file_count(session, material.course_id)
|
| 110 |
+
|
| 111 |
+
stamp_note = ""
|
| 112 |
+
if cfg.watermark_enabled and (material.source_name or "").lower().endswith(".pdf") and not material.watermarked:
|
| 113 |
+
target = cfg.library_channel_id or (next(iter(cfg.admin_id_set), None) or sub.user_id)
|
| 114 |
+
ok, reason = await smart_ingest.stamp_existing(bot, material, target)
|
| 115 |
+
stamp_note = " · 🔖 stamped" if ok else f" · not stamped ({reason})"
|
| 116 |
+
|
| 117 |
+
if replace_id:
|
| 118 |
+
old = await lib_repo.get_material(session, replace_id)
|
| 119 |
+
if old:
|
| 120 |
+
material.downloads = (material.downloads or 0) + (old.downloads or 0)
|
| 121 |
+
await lib_repo.delete_material(session, old.id)
|
| 122 |
+
material.status = "ok"
|
| 123 |
+
if submitter and submitter.credit_name:
|
| 124 |
+
material.credit = (submitter.full_name or "").strip().split(" ")[0][:40] or None
|
| 125 |
+
|
| 126 |
+
gained = 0
|
| 127 |
+
badge_tier = None
|
| 128 |
+
if submitter:
|
| 129 |
+
rewarded = await xp_repo.count_kind_in_week(session, submitter.id, "contribution", season.week_key())
|
| 130 |
+
xp, bonus = K.reward(
|
| 131 |
+
rewarded_this_week=rewarded, course_files_before=files_before, xp=cfg.contribution_xp,
|
| 132 |
+
bonus=cfg.contribution_bonus_xp, weekly_cap=cfg.contribution_weekly_cap,
|
| 133 |
+
)
|
| 134 |
+
main = await xp_service.award(session, submitter, "contribution", xp, ref=f"sub{sub.id}", capped=False)
|
| 135 |
+
extra = await xp_service.award(session, submitter, "contribution_bonus", bonus, ref=f"subb{sub.id}", capped=False)
|
| 136 |
+
gained = main.amount + extra.amount
|
| 137 |
+
session.sync_session.info.setdefault("invalidate", set()).add(submitter.id)
|
| 138 |
+
sub.status, sub.reviewed_at, sub.xp = "approved", datetime.now(timezone.utc), gained
|
| 139 |
+
session.sync_session.info["wrote"] = True
|
| 140 |
+
await session.flush()
|
| 141 |
+
library_service.invalidate_courses()
|
| 142 |
+
|
| 143 |
+
if submitter:
|
| 144 |
+
approved_total = await sub_repo.approved_count(session, submitter.id)
|
| 145 |
+
course = await lib_repo.get_course(session, material.course_id)
|
| 146 |
+
await safe_send(
|
| 147 |
+
bot, submitter.id,
|
| 148 |
+
t(submitter.lang, "contrib_approved", title=material.title, course=course.name if course else "", xp=gained),
|
| 149 |
+
)
|
| 150 |
+
badge_tier = K.badge_for(approved_total) if approved_total in {n for n, _ in K.BADGES} else None
|
| 151 |
+
if badge_tier:
|
| 152 |
+
await safe_send(bot, submitter.id, t(submitter.lang, "contrib_badge", badge=t(submitter.lang, f"badge_{badge_tier}")))
|
| 153 |
+
return Decision(True, f"✅ Approved #{material.id} “{material.title}” (+{gained} XP{stamp_note})")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def reject(bot: Bot, session: AsyncSession, sub: Submission, reason: str) -> Decision:
|
| 157 |
+
reason = reason if reason in K.REJECT_REASONS else "quality"
|
| 158 |
+
material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None
|
| 159 |
+
title = material.title if material else (sub.file_name or "")
|
| 160 |
+
if material:
|
| 161 |
+
await lib_repo.delete_material(session, material.id)
|
| 162 |
+
now = datetime.now(timezone.utc)
|
| 163 |
+
sub.status, sub.reason, sub.reviewed_at = "rejected", reason, now
|
| 164 |
+
session.sync_session.info["wrote"] = True
|
| 165 |
+
await session.flush()
|
| 166 |
+
submitter = await users_repo.get(session, sub.user_id)
|
| 167 |
+
if submitter:
|
| 168 |
+
text = t(submitter.lang, f"contrib_rejected_{reason}", title=title)
|
| 169 |
+
rejected = await sub_repo.rejected_times(session, submitter.id, now - timedelta(days=K.REJECTION_WINDOW_DAYS))
|
| 170 |
+
if K.blocked_until(rejected, now):
|
| 171 |
+
text += t(submitter.lang, "contrib_blocked_notice")
|
| 172 |
+
await safe_send(bot, submitter.id, text)
|
| 173 |
+
return Decision(True, f"🗑 Rejected ({reason}): “{title}”")
|
app/services/library.py
CHANGED
|
@@ -83,6 +83,8 @@ async def send_material(bot: Bot, chat_id: int, material: Material, course: Opti
|
|
| 83 |
caption = f"📄 {fuzzy_display(material.title)}"
|
| 84 |
if course:
|
| 85 |
caption += f"\n📚 {course.name}"
|
|
|
|
|
|
|
| 86 |
caption += f"\n#{material.kind}"
|
| 87 |
caption += f"\n🤖 @{(await bot.me()).username}"
|
| 88 |
kwargs = {material.file_type: material.file_id, "caption": caption}
|
|
|
|
| 83 |
caption = f"📄 {fuzzy_display(material.title)}"
|
| 84 |
if course:
|
| 85 |
caption += f"\n📚 {course.name}"
|
| 86 |
+
if material.credit:
|
| 87 |
+
caption += f"\n✍️ {material.credit}"
|
| 88 |
caption += f"\n#{material.kind}"
|
| 89 |
caption += f"\n🤖 @{(await bot.me()).username}"
|
| 90 |
kwargs = {material.file_type: material.file_id, "caption": caption}
|
app/services/qr.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""QR codes as PNG images (for Canva, stickers, social posts) using only reportlab's encoder + Pillow."""
|
| 2 |
+
import io
|
| 3 |
+
|
| 4 |
+
from PIL import Image, ImageDraw
|
| 5 |
+
from reportlab.graphics.barcode import qrencoder
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def qr_matrix(data: str) -> list[list[bool]]:
|
| 9 |
+
qr = qrencoder.QRCode(None, qrencoder.QRErrorCorrectLevel.M) # None = pick the smallest version that fits
|
| 10 |
+
qr.addData(data)
|
| 11 |
+
qr.make()
|
| 12 |
+
n = qr.getModuleCount()
|
| 13 |
+
return [[bool(qr.isDark(r, c)) for c in range(n)] for r in range(n)]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def qr_png(data: str, *, box: int = 26, border: int = 4, transparent: bool = False) -> bytes:
|
| 17 |
+
"""Black modules on white (or transparent) with the 4-module quiet zone scanners need."""
|
| 18 |
+
matrix = qr_matrix(data)
|
| 19 |
+
n = len(matrix)
|
| 20 |
+
size = (n + 2 * border) * box
|
| 21 |
+
mode, bg, fg = ("RGBA", (0, 0, 0, 0), (0, 0, 0, 255)) if transparent else ("RGB", (255, 255, 255), (0, 0, 0))
|
| 22 |
+
img = Image.new(mode, (size, size), bg)
|
| 23 |
+
draw = ImageDraw.Draw(img)
|
| 24 |
+
for r, row in enumerate(matrix):
|
| 25 |
+
for c, dark in enumerate(row):
|
| 26 |
+
if dark:
|
| 27 |
+
x, y = (c + border) * box, (r + border) * box
|
| 28 |
+
draw.rectangle([x, y, x + box - 1, y + box - 1], fill=fg)
|
| 29 |
+
out = io.BytesIO()
|
| 30 |
+
img.save(out, format="PNG")
|
| 31 |
+
return out.getvalue()
|
app/services/smart_ingest.py
CHANGED
|
@@ -62,6 +62,7 @@ async def ingest_file(
|
|
| 62 |
forced_title: Optional[str] = None,
|
| 63 |
backup_channel: int = 0,
|
| 64 |
message=None,
|
|
|
|
| 65 |
) -> Outcome:
|
| 66 |
# 1) exact same Telegram file
|
| 67 |
existing = await repo.find_by_unique_id(session, unique_id)
|
|
@@ -128,7 +129,7 @@ async def ingest_file(
|
|
| 128 |
# 7b) stamp PDFs with the official channel link; students receive the STAMPED copy
|
| 129 |
stored_file_id, orig_file_id, stamped_ok = file_id, None, False
|
| 130 |
library_chat = library_msg = None
|
| 131 |
-
if get_settings().watermark_enabled and ext == ".pdf" and file_type == "document":
|
| 132 |
if data is None:
|
| 133 |
big = size is not None and size > BOT_API_DOWNLOAD_LIMIT
|
| 134 |
notes.append("not watermarked: over 20 MB, the bot cannot read it" if big else "not watermarked: could not read the file")
|
|
@@ -187,3 +188,23 @@ async def ingest_file(
|
|
| 187 |
status, material=material, course=course, kind=kind, title=material.title, why=why,
|
| 188 |
notes=notes, suggestions=suggestions, duplicate_of=near, file_name=file_name,
|
| 189 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
forced_title: Optional[str] = None,
|
| 63 |
backup_channel: int = 0,
|
| 64 |
message=None,
|
| 65 |
+
stamp: bool = True,
|
| 66 |
) -> Outcome:
|
| 67 |
# 1) exact same Telegram file
|
| 68 |
existing = await repo.find_by_unique_id(session, unique_id)
|
|
|
|
| 129 |
# 7b) stamp PDFs with the official channel link; students receive the STAMPED copy
|
| 130 |
stored_file_id, orig_file_id, stamped_ok = file_id, None, False
|
| 131 |
library_chat = library_msg = None
|
| 132 |
+
if stamp and get_settings().watermark_enabled and ext == ".pdf" and file_type == "document":
|
| 133 |
if data is None:
|
| 134 |
big = size is not None and size > BOT_API_DOWNLOAD_LIMIT
|
| 135 |
notes.append("not watermarked: over 20 MB, the bot cannot read it" if big else "not watermarked: could not read the file")
|
|
|
|
| 188 |
status, material=material, course=course, kind=kind, title=material.title, why=why,
|
| 189 |
notes=notes, suggestions=suggestions, duplicate_of=near, file_name=file_name,
|
| 190 |
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
async def stamp_existing(bot: Bot, material: Material, target_chat: int) -> tuple[bool, str]:
|
| 194 |
+
"""Stamp a PDF that is already stored (a student's file at approval time). (ok, reason when not ok)"""
|
| 195 |
+
data = await _download(bot, material.orig_file_id or material.file_id)
|
| 196 |
+
if not data:
|
| 197 |
+
return False, "could not download"
|
| 198 |
+
stamped, reason = await wm.stamp(data, wm.current_style())
|
| 199 |
+
if stamped is None:
|
| 200 |
+
return False, reason
|
| 201 |
+
try:
|
| 202 |
+
sent = await bot.send_document(
|
| 203 |
+
target_chat, BufferedInputFile(stamped, filename=material.source_name or "document.pdf"),
|
| 204 |
+
caption=f"📌 {material.title}"[:200], disable_notification=True,
|
| 205 |
+
)
|
| 206 |
+
except Exception as exc:
|
| 207 |
+
return False, f"upload failed: {type(exc).__name__}"
|
| 208 |
+
material.orig_file_id = material.orig_file_id or material.file_id
|
| 209 |
+
material.file_id, material.watermarked = sent.document.file_id, True
|
| 210 |
+
return True, ""
|
app/services/users.py
CHANGED
|
@@ -13,7 +13,7 @@ SNAPSHOT_COLS = (
|
|
| 13 |
"id", "username", "full_name", "lang", "stream", "year", "referred_by",
|
| 14 |
"gate_passed", "points", "streak", "best_streak", "last_active_on", "is_banned",
|
| 15 |
"active_days", "week_key", "week_xp", "day_key", "day_xp", "trust",
|
| 16 |
-
"source", "pending_link", "share_prompt_day",
|
| 17 |
)
|
| 18 |
|
| 19 |
|
|
|
|
| 13 |
"id", "username", "full_name", "lang", "stream", "year", "referred_by",
|
| 14 |
"gate_passed", "points", "streak", "best_streak", "last_active_on", "is_banned",
|
| 15 |
"active_days", "week_key", "week_xp", "day_key", "day_xp", "trust",
|
| 16 |
+
"source", "pending_link", "share_prompt_day", "credit_name",
|
| 17 |
)
|
| 18 |
|
| 19 |
|
app/tasks/jobs.py
CHANGED
|
@@ -4,11 +4,13 @@ import logging
|
|
| 4 |
from datetime import datetime, timedelta, timezone
|
| 5 |
|
| 6 |
from aiogram import Bot
|
|
|
|
| 7 |
|
| 8 |
from app.core import cache, season
|
| 9 |
from app.core.i18n import t
|
| 10 |
from app.core.notify import notify_admins, safe_send
|
| 11 |
from app.db.repos import referrals as ref_repo
|
|
|
|
| 12 |
from app.db.repos import users as users_repo
|
| 13 |
from app.db.repos import xp as xp_repo
|
| 14 |
from app.db.session import SessionFactory
|
|
@@ -82,6 +84,22 @@ async def close_previous_week(bot: Bot) -> None:
|
|
| 82 |
await notify_admins(bot, summary)
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
async def periodic(name: str, interval: int, fn, bot: Bot, first_delay: int = 60) -> None:
|
| 86 |
await asyncio.sleep(first_delay)
|
| 87 |
while True:
|
|
|
|
| 4 |
from datetime import datetime, timedelta, timezone
|
| 5 |
|
| 6 |
from aiogram import Bot
|
| 7 |
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
| 8 |
|
| 9 |
from app.core import cache, season
|
| 10 |
from app.core.i18n import t
|
| 11 |
from app.core.notify import notify_admins, safe_send
|
| 12 |
from app.db.repos import referrals as ref_repo
|
| 13 |
+
from app.db.repos import submissions as sub_repo
|
| 14 |
from app.db.repos import users as users_repo
|
| 15 |
from app.db.repos import xp as xp_repo
|
| 16 |
from app.db.session import SessionFactory
|
|
|
|
| 84 |
await notify_admins(bot, summary)
|
| 85 |
|
| 86 |
|
| 87 |
+
async def notify_pending_submissions(bot: Bot) -> None:
|
| 88 |
+
"""Tell the admin about new student files in ONE message with a button that opens the review."""
|
| 89 |
+
async with SessionFactory() as session:
|
| 90 |
+
fresh = await sub_repo.unnotified(session)
|
| 91 |
+
if not fresh:
|
| 92 |
+
return
|
| 93 |
+
total = await sub_repo.pending_total(session)
|
| 94 |
+
await sub_repo.mark_notified(session, [s.id for s in fresh])
|
| 95 |
+
await session.commit()
|
| 96 |
+
await notify_admins(
|
| 97 |
+
bot,
|
| 98 |
+
f"📥 <b>{len(fresh)}</b> new file(s) from students · {total} waiting in total",
|
| 99 |
+
reply_markup=InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="▶️ Review now", callback_data="qq:start")]]),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
async def periodic(name: str, interval: int, fn, bot: Bot, first_delay: int = 60) -> None:
|
| 104 |
await asyncio.sleep(first_delay)
|
| 105 |
while True:
|
locales/am.json
CHANGED
|
@@ -37,7 +37,7 @@
|
|
| 37 |
"file_missing": "ይሄ ፋይል አልተገኘም 😕",
|
| 38 |
"search_none": "🔍 ምንም አላገኘሁም። ሌላ ቃል ሞክሩ፣ ለምሳሌ የኮርሱን ስም ወይም ኮድ።",
|
| 39 |
"search_results": "🔍 <b>ለ «{q}» ያገኘሁት</b>",
|
| 40 |
-
"profile_text": "👤 <b>{name}</b>\n🎓 {stream} · {year}\n\n🏅 ደረጃ {level}፦ <b>{title}</b>\n⭐ ጠቅላላ XP፦ <b>{points}</b> ({to_next})\n📅 የዚህ ሳምንት XP፦ <b>{week_xp}</b> · {week_rank}\n🔥 ተከታታይ ቀናት፦ <b>{streak}</b> (ከፍተኛው {best})\n👥 የጋበዛችኋቸው፦ <b>{refs}</b> (ንቁ {refs_q})",
|
| 41 |
"top_text": "🏆 <b>የሳምንቱ ደረጃ</b> · {days} ቀን ቀርቷል\n{rows}\n\n📍 እናንተ፦ <b>{rank}</b> · {week_xp} XP\n⚔️ {n_label}፦ <b>{natural}</b> · {s_label}፦ <b>{social}</b>{prize}\n\nℹ️ ህጎቹ፦ /rules",
|
| 42 |
"inline_open_bot": "📚 ቦቱን ክፈቱ",
|
| 43 |
"prize_block": "\n\n💰 <b>የሳምንቱ ሽልማት</b> (የሞባይል ካርድ)፦\n{lines}\n🎟 እጣ፦ {raffle} ብር (ለቀሩት ብቁ ተወዳዳሪዎች)\nለመወዳደር፦ በሳምንቱ ቢያንስ {days} ቀን መግባትና {xp} XP መሰብሰብ ያስፈልጋል",
|
|
@@ -69,5 +69,42 @@
|
|
| 69 |
"group_find_result": "🔍 <b>ለ «{q}» ያገኘሁት</b>\nንኩና ቦቱ ውስጥ ክፈቱ 👇",
|
| 70 |
"share_btn": "📤 ለጓደኛ አጋሩ",
|
| 71 |
"share_text": "📚 ለDBU ተማሪዎች ነጻ ማቴሪያሎች፦ ስላይድ፣ የድሮ ፈተናዎች፣ ሞጁሎች — ሁሉም በአንድ ቦታ። እዚህ ጀምሩ 👇",
|
| 72 |
-
"share_prompt": "👍 ጠቀማችሁ? ለክፍል ጓደኞቻችሁም አጋሩላቸው፤ በሊንካችሁ ሲገቡ XP ታገኛላችሁ 🎁"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
|
|
|
| 37 |
"file_missing": "ይሄ ፋይል አልተገኘም 😕",
|
| 38 |
"search_none": "🔍 ምንም አላገኘሁም። ሌላ ቃል ሞክሩ፣ ለምሳሌ የኮርሱን ስም ወይም ኮድ።",
|
| 39 |
"search_results": "🔍 <b>ለ «{q}» ያገኘሁት</b>",
|
| 40 |
+
"profile_text": "👤 <b>{name}</b>\n🎓 {stream} · {year}\n\n🏅 ደረጃ {level}፦ <b>{title}</b>\n⭐ ጠቅላላ XP፦ <b>{points}</b> ({to_next})\n📅 የዚህ ሳምንት XP፦ <b>{week_xp}</b> · {week_rank}\n🔥 ተከታታይ ቀናት፦ <b>{streak}</b> (ከፍተኛው {best})\n👥 የጋበዛችኋቸው፦ <b>{refs}</b> (ንቁ {refs_q}){badge}",
|
| 41 |
"top_text": "🏆 <b>የሳምንቱ ደረጃ</b> · {days} ቀን ቀርቷል\n{rows}\n\n📍 እናንተ፦ <b>{rank}</b> · {week_xp} XP\n⚔️ {n_label}፦ <b>{natural}</b> · {s_label}፦ <b>{social}</b>{prize}\n\nℹ️ ህጎቹ፦ /rules",
|
| 42 |
"inline_open_bot": "📚 ቦቱን ክፈቱ",
|
| 43 |
"prize_block": "\n\n💰 <b>የሳምንቱ ሽልማት</b> (የሞባይል ካርድ)፦\n{lines}\n🎟 እጣ፦ {raffle} ብር (ለቀሩት ብቁ ተወዳዳሪዎች)\nለመወዳደር፦ በሳምንቱ ቢያንስ {days} ቀን መግባትና {xp} XP መሰብሰብ ያስፈልጋል",
|
|
|
|
| 69 |
"group_find_result": "🔍 <b>ለ «{q}» ያገኘሁት</b>\nንኩና ቦቱ ውስጥ ክፈቱ 👇",
|
| 70 |
"share_btn": "📤 ለጓደኛ አጋሩ",
|
| 71 |
"share_text": "📚 ለDBU ተማሪዎች ነጻ ማቴሪያሎች፦ ስላይድ፣ የድሮ ፈተናዎች፣ ሞጁሎች — ሁሉም በአንድ ቦታ። እዚህ ጀምሩ 👇",
|
| 72 |
+
"share_prompt": "👍 ጠቀማችሁ? ለክፍል ጓደኞቻችሁም አጋሩላቸው፤ በሊንካችሁ ሲገቡ XP ታገኛላችሁ 🎁",
|
| 73 |
+
"btn_contribute": "📤 ፋይል አስገቡ",
|
| 74 |
+
"contrib_panel": "📤 <b>ፋይል አስገቡ</b>\nየሚጠቅም ማቴሪያል (ስላይድ፣ ሞጁል፣ የድሮ ፈተና፣ ኖት) አላችሁ? ላኩልን፣ ለሁሉም ተማሪ ይጠቅማል 🙌\n\n<b>ህጎች</b>\n• PDF፣ PPTX ወይም DOCX፣ እስከ 20 MB\n• የንግድ መጽሐፍ ወይም የሌላ ሰው የተከለከለ ይዘት አይሆንም\n• ተመሳሳይ ፋይል አስቀድሞ ካለ ወዲያው እነግራችኋለሁ\n• አድሚኑ ካየ በኋላ ነው ማቴሪያሎች ውስጥ የሚገባው\n\n🎁 ሲጸድቅ +{xp} XP (ፋይል የሚፈለግበት ኮርስ ከሆነ +{bonus} ጉርሻ)፣ በሳምንት እስከ {cap} ፋይል\n\n✅ የጸደቁ፦ <b>{approved}</b> · ⏳ በመጠባበቅ፦ <b>{pending}</b>{badge}{reason}",
|
| 75 |
+
"contrib_next_badge": "ለ{badge} {n} ፋይል ቀርቷል",
|
| 76 |
+
"contrib_btn_go": "📤 ፋይል ልላክ",
|
| 77 |
+
"contrib_btn_wanted": "🎯 የሚፈለጉ ኮርሶች",
|
| 78 |
+
"contrib_credit_on": "✍️ ስሜ ይጠቀስ: ✅",
|
| 79 |
+
"contrib_credit_off": "✍️ ስሜ ይጠቀስ: ❌",
|
| 80 |
+
"contrib_credit_saved_on": "እሺ፣ በሚጸድቁ ፋይሎች ላይ ስማችሁ ይጠቀሳል",
|
| 81 |
+
"contrib_credit_saved_off": "እሺ፣ ስማችሁ አይጠቀስም",
|
| 82 |
+
"contrib_send_now": "እሺ! አሁን ፋይሉን ላኩ 📎 (PDF፣ PPTX ወይም DOCX)።\nብዙ ፋይል አንድ በአንድ መላክ ትችላላችሁ፤ ለ10 ደቂቃ ክፍት ነው።",
|
| 83 |
+
"contrib_send_now_course": "እሺ! ለ<b>{course}</b> ፋይል ላኩ 📎 (PDF፣ PPTX ወይም DOCX)።\nለ10 ደቂቃ ክፍት ነው።",
|
| 84 |
+
"contrib_tap_first": "ፋይል ለማስገባት መጀመሪያ «📤 ፋይል አስገቡ» ንኩና «ፋይል ልላክ» ምረጡ 🙂",
|
| 85 |
+
"contrib_not_doc": "PDF፣ PPTX ወይም DOCX ብቻ ነው የምቀበለው 🙏",
|
| 86 |
+
"contrib_too_big": "ፋይሉ ከ20 MB ይበልጣል፤ ለሁለት ከፍላችሁ ላኩት።",
|
| 87 |
+
"contrib_dup": "♻️ ይሄ ፋይል አስቀድሞ ማቴሪያሎች ውስጥ አለ፦ «{title}»። ሌላ ካላችሁ ላኩ!",
|
| 88 |
+
"contrib_accepted": "✅ ተቀብያለሁ!\n📎 «{title}»\n📘 {course} · {kind}\n\nአድሚኑ ሲያጸድቀው ማቴሪያሎች ውስጥ ይገባል፤ +{xp} XP ታገኛላችሁ{bonus} 🎁{note}",
|
| 89 |
+
"contrib_bonus": " (+{n} ጉርሻ)",
|
| 90 |
+
"contrib_near_note": "\n🔍 ተመሳሳይ ፋይል ስላለ አድሚኑ ያጣራዋል።",
|
| 91 |
+
"contrib_need_course": "🤔 «{title}» የየትኛው ኮርስ ነው? ምረጡ 👇",
|
| 92 |
+
"contrib_course_set": "✅ «{title}» → {course}። አድሚኑ ሲያጸድቀው እነግራችኋለሁ።",
|
| 93 |
+
"contrib_more_courses": "🔎 ሌላ ኮርስ",
|
| 94 |
+
"contrib_change_course": "✏️ ኮርስ ቀይር",
|
| 95 |
+
"contrib_wanted": "🎯 <b>እነዚህ ኮርሶች ማቴሪያል ይፈልጋሉ</b>\nአንዱን ንኩና ፋይል ላኩ፤ ተጨማሪ ጉርሻ ታገኛላችሁ 🎁",
|
| 96 |
+
"contrib_wanted_none": "🎉 ሁሉም ኮርሶች በቂ ማቴሪያል አላቸው! ሌላ ጠቃሚ ፋይል ካላችሁ አሁንም ላኩ።",
|
| 97 |
+
"contrib_reason_new": "\n\n⚠️ ፋይል ለማስገባት ቢያንስ {days} ቀን ቦቱን መጠቀም ያስፈልጋል።",
|
| 98 |
+
"contrib_reason_blocked": "\n\n⏸ ብዙ ፋይሎች ተቀባይነት ስላላገኙ ለጥቂት ቀን ማስገባት ተዘግቷል።",
|
| 99 |
+
"contrib_reason_pending": "\n\n⏳ ብዙ ፋይሎች እየተጠበቁ ነው፤ አድሚኑ እስኪያያቸው ጠብቁ።",
|
| 100 |
+
"contrib_reason_daily": "\n\n📅 ዛሬ የሚፈቀደውን ያህል ልካችኋል፤ ነገ ቀጥሉ።",
|
| 101 |
+
"contrib_approved": "🎉 ፋይላችሁ ጸድቋል!\n📎 «{title}» → {course}\n+{xp} XP · ለሁሉም ተማሪ ይጠቅማል፣ አመሰግናለሁ! 🙏",
|
| 102 |
+
"contrib_badge": "🏅 አዲስ ባጅ፦ {badge}!",
|
| 103 |
+
"contrib_rejected_duplicate": "«{title}» አልተቀበለም፦ ተመሳሳይ ፋይል አስቀድሞ አለ። ሌላ ፋይል ብትልኩ ደስ ይለናል 🙂",
|
| 104 |
+
"contrib_rejected_quality": "«{title}» አልተቀበለም፦ ጥራቱ (ግልጽነቱ ወይም ይዘቱ) በቂ አይደለም። ግልጽ የሆነ ቅጂ ካላችሁ ላኩ።",
|
| 105 |
+
"contrib_rejected_notallowed": "«{title}» አልተቀበለም፦ የንግድ መጽሐፍ ወይም ከኮርሱ ጋር የማይገናኝ ይዘት ነው።",
|
| 106 |
+
"contrib_blocked_notice": "\n⏸ ለጥቂት ቀን አዲስ ፋይል ማስገባት ተዘግቷል።",
|
| 107 |
+
"badge_bronze": "🥉 አስተዋጽኦ አድራጊ",
|
| 108 |
+
"badge_silver": "🥈 ብር አስተዋጽኦ አድራጊ",
|
| 109 |
+
"badge_gold": "🥇 ወርቅ አስተዋጽኦ አድራጊ"
|
| 110 |
}
|
locales/en.json
CHANGED
|
@@ -37,7 +37,7 @@
|
|
| 37 |
"file_missing": "This file was not found.",
|
| 38 |
"search_none": "🔍 Nothing found. Try another word (course name or code).",
|
| 39 |
"search_results": "🔍 <b>Results</b> for «{q}»",
|
| 40 |
-
"profile_text": "👤 <b>{name}</b>\n🎓 {stream} · {year}\n\n🏅 Level {level}: <b>{title}</b>\n⭐ Total XP: <b>{points}</b> ({to_next})\n📅 This week's XP: <b>{week_xp}</b> · {week_rank}\n🔥 Streak: <b>{streak}</b> days (best {best})\n👥 Invited: <b>{refs}</b> (active {refs_q})",
|
| 41 |
"top_text": "🏆 <b>Weekly ranking</b> · {days} days left\n{rows}\n\n📍 You: <b>{rank}</b> · {week_xp} XP\n⚔️ {n_label}: <b>{natural}</b> · {s_label}: <b>{social}</b>{prize}\n\nℹ️ Rules: /rules",
|
| 42 |
"inline_open_bot": "📚 Open the bot",
|
| 43 |
"prize_block": "\n\n💰 <b>Weekly prizes</b> (airtime card, birr):\n{lines}\n🎟 Raffle: {raffle} birr (among other eligible players)\nTo qualify: be active {days}+ days this week and earn {xp}+ XP",
|
|
@@ -69,5 +69,42 @@
|
|
| 69 |
"group_find_result": "🔍 <b>Found for «{q}»</b>\nTap to open it in the bot 👇",
|
| 70 |
"share_btn": "📤 Share with a friend",
|
| 71 |
"share_text": "📚 Free study materials for DBU students: slides, past exams, modules, all in one place. Start here 👇",
|
| 72 |
-
"share_prompt": "👍 Was that useful? Share it with your classmates; you get XP when they join with your link 🎁"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
|
|
|
| 37 |
"file_missing": "This file was not found.",
|
| 38 |
"search_none": "🔍 Nothing found. Try another word (course name or code).",
|
| 39 |
"search_results": "🔍 <b>Results</b> for «{q}»",
|
| 40 |
+
"profile_text": "👤 <b>{name}</b>\n🎓 {stream} · {year}\n\n🏅 Level {level}: <b>{title}</b>\n⭐ Total XP: <b>{points}</b> ({to_next})\n📅 This week's XP: <b>{week_xp}</b> · {week_rank}\n🔥 Streak: <b>{streak}</b> days (best {best})\n👥 Invited: <b>{refs}</b> (active {refs_q}){badge}",
|
| 41 |
"top_text": "🏆 <b>Weekly ranking</b> · {days} days left\n{rows}\n\n📍 You: <b>{rank}</b> · {week_xp} XP\n⚔️ {n_label}: <b>{natural}</b> · {s_label}: <b>{social}</b>{prize}\n\nℹ️ Rules: /rules",
|
| 42 |
"inline_open_bot": "📚 Open the bot",
|
| 43 |
"prize_block": "\n\n💰 <b>Weekly prizes</b> (airtime card, birr):\n{lines}\n🎟 Raffle: {raffle} birr (among other eligible players)\nTo qualify: be active {days}+ days this week and earn {xp}+ XP",
|
|
|
|
| 69 |
"group_find_result": "🔍 <b>Found for «{q}»</b>\nTap to open it in the bot 👇",
|
| 70 |
"share_btn": "📤 Share with a friend",
|
| 71 |
"share_text": "📚 Free study materials for DBU students: slides, past exams, modules, all in one place. Start here 👇",
|
| 72 |
+
"share_prompt": "👍 Was that useful? Share it with your classmates; you get XP when they join with your link 🎁",
|
| 73 |
+
"btn_contribute": "📤 Contribute a file",
|
| 74 |
+
"contrib_panel": "📤 <b>Contribute a file</b>\nGot useful material (slides, a module, a past exam, notes)? Send it, it helps every student 🙌\n\n<b>Rules</b>\n• PDF, PPTX or DOCX, up to 20 MB\n• No commercial books or content you are not allowed to share\n• If the file already exists I'll tell you right away\n• It appears in the library only after an admin has checked it\n\n🎁 +{xp} XP when approved (+{bonus} bonus if the course is short of material), up to {cap} files a week\n\n✅ Approved: <b>{approved}</b> · ⏳ Waiting: <b>{pending}</b>{badge}{reason}",
|
| 75 |
+
"contrib_next_badge": "{n} more file(s) for {badge}",
|
| 76 |
+
"contrib_btn_go": "📤 Send a file",
|
| 77 |
+
"contrib_btn_wanted": "🎯 Courses that need files",
|
| 78 |
+
"contrib_credit_on": "✍️ Credit my name: ✅",
|
| 79 |
+
"contrib_credit_off": "✍️ Credit my name: ❌",
|
| 80 |
+
"contrib_credit_saved_on": "OK, your name will appear on approved files",
|
| 81 |
+
"contrib_credit_saved_off": "OK, your name will not be shown",
|
| 82 |
+
"contrib_send_now": "OK! Send the file now 📎 (PDF, PPTX or DOCX).\nYou can send several, one by one; this stays open for 10 minutes.",
|
| 83 |
+
"contrib_send_now_course": "OK! Send a file for <b>{course}</b> 📎 (PDF, PPTX or DOCX).\nThis stays open for 10 minutes.",
|
| 84 |
+
"contrib_tap_first": "To contribute a file, first tap «📤 Contribute a file» and choose «Send a file» 🙂",
|
| 85 |
+
"contrib_not_doc": "I only accept PDF, PPTX or DOCX files 🙏",
|
| 86 |
+
"contrib_too_big": "That file is over 20 MB; please split it in two and send both.",
|
| 87 |
+
"contrib_dup": "♻️ That file is already in the library: “{title}”. Send another if you have one!",
|
| 88 |
+
"contrib_accepted": "✅ Got it!\n📎 “{title}”\n📘 {course} · {kind}\n\nOnce an admin approves it, it goes into the library; you'll get +{xp} XP{bonus} 🎁{note}",
|
| 89 |
+
"contrib_bonus": " (+{n} bonus)",
|
| 90 |
+
"contrib_near_note": "\n🔍 A similar file exists, so an admin will double-check.",
|
| 91 |
+
"contrib_need_course": "🤔 Which course is “{title}” for? Pick one 👇",
|
| 92 |
+
"contrib_course_set": "✅ “{title}” → {course}. I'll tell you when an admin approves it.",
|
| 93 |
+
"contrib_more_courses": "🔎 Another course",
|
| 94 |
+
"contrib_change_course": "✏️ Change course",
|
| 95 |
+
"contrib_wanted": "🎯 <b>These courses need material</b>\nTap one and send a file; you get a bonus 🎁",
|
| 96 |
+
"contrib_wanted_none": "🎉 Every course has enough material! If you have another useful file, send it anyway.",
|
| 97 |
+
"contrib_reason_new": "\n\n⚠️ You need to have used the bot for at least {days} days before sending files.",
|
| 98 |
+
"contrib_reason_blocked": "\n\n⏸ Several files were not accepted, so sending is paused for a few days.",
|
| 99 |
+
"contrib_reason_pending": "\n\n⏳ Several files are still waiting; please wait until an admin has looked at them.",
|
| 100 |
+
"contrib_reason_daily": "\n\n📅 You have sent as many as allowed today; continue tomorrow.",
|
| 101 |
+
"contrib_approved": "🎉 Your file was approved!\n📎 “{title}” → {course}\n+{xp} XP · it helps every student, thank you! 🙏",
|
| 102 |
+
"contrib_badge": "🏅 New badge: {badge}!",
|
| 103 |
+
"contrib_rejected_duplicate": "“{title}” was not accepted: a similar file already exists. We'd love another one 🙂",
|
| 104 |
+
"contrib_rejected_quality": "“{title}” was not accepted: the quality (clarity or content) is not good enough. Send a clearer copy if you have one.",
|
| 105 |
+
"contrib_rejected_notallowed": "“{title}” was not accepted: it is a commercial book or unrelated to the course.",
|
| 106 |
+
"contrib_blocked_notice": "\n⏸ Sending new files is paused for a few days.",
|
| 107 |
+
"badge_bronze": "🥉 Contributor",
|
| 108 |
+
"badge_silver": "🥈 Silver contributor",
|
| 109 |
+
"badge_gold": "🥇 Gold contributor"
|
| 110 |
}
|
tests/test_logic.py
CHANGED
|
@@ -394,6 +394,56 @@ def test_poster_is_a_valid_one_page_pdf():
|
|
| 394 |
assert "@DBUFreshBot" in text and "DBU Freshman Central" in text and "t.me/DBUCentral" in text
|
| 395 |
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
if __name__ == "__main__":
|
| 398 |
for name, fn in list(globals().items()):
|
| 399 |
if name.startswith("test_"):
|
|
|
|
| 394 |
assert "@DBUFreshBot" in text and "DBU Freshman Central" in text and "t.me/DBUCentral" in text
|
| 395 |
|
| 396 |
|
| 397 |
+
def test_contribution_rules():
|
| 398 |
+
from datetime import datetime, timedelta
|
| 399 |
+
|
| 400 |
+
from app.services import contrib_rules as K
|
| 401 |
+
|
| 402 |
+
assert K.check_file("a.pdf", 1000) is None and K.check_file("A.PPTX", 1000) is None
|
| 403 |
+
assert K.check_file("a.exe", 1000) == "ext" and K.check_file("a.pdf", K.MAX_BYTES + 1) == "size"
|
| 404 |
+
assert K.check_file("a.pdf", None) == "size"
|
| 405 |
+
|
| 406 |
+
now = datetime(2026, 10, 20, 12, 0)
|
| 407 |
+
day = lambda n: now - timedelta(days=n) # noqa: E731
|
| 408 |
+
assert K.blocked_until([day(1), day(2)], now) is None # two rejections: still fine
|
| 409 |
+
assert K.blocked_until([day(1), day(2), day(3)], now) == day(1) + timedelta(days=7)
|
| 410 |
+
assert K.blocked_until([day(20), day(21), day(22)], now) is None # too old to count
|
| 411 |
+
assert K.blocked_until([day(9), day(10), day(11)], now) is None # block (7 days after day 9) already over
|
| 412 |
+
|
| 413 |
+
ok = dict(active_days=3, min_days=2, pending=0, pending_limit=5, today=0, daily_limit=8, blocked=None)
|
| 414 |
+
assert K.eligibility(**ok) is None
|
| 415 |
+
assert K.eligibility(**{**ok, "active_days": 1}) == "new"
|
| 416 |
+
assert K.eligibility(**{**ok, "pending": 5}) == "pending"
|
| 417 |
+
assert K.eligibility(**{**ok, "today": 8}) == "daily"
|
| 418 |
+
assert K.eligibility(**{**ok, "blocked": now}) == "blocked"
|
| 419 |
+
|
| 420 |
+
assert [K.badge_for(n) for n in (0, 1, 4, 5, 14, 15)] == [None, "bronze", "bronze", "silver", "silver", "gold"]
|
| 421 |
+
assert K.next_badge(0) == (1, "bronze") and K.next_badge(3) == (2, "silver") and K.next_badge(15) is None
|
| 422 |
+
assert K.reward(rewarded_this_week=0, course_files_before=0, xp=20, bonus=5, weekly_cap=5) == (20, 5)
|
| 423 |
+
assert K.reward(rewarded_this_week=0, course_files_before=9, xp=20, bonus=5, weekly_cap=5) == (20, 0)
|
| 424 |
+
assert K.reward(rewarded_this_week=5, course_files_before=0, xp=20, bonus=5, weekly_cap=5) == (0, 0)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def test_qr_png_really_scans():
|
| 428 |
+
"""Decode the PNG with OpenCV's QR reader: proves the code is valid, not just pretty."""
|
| 429 |
+
try:
|
| 430 |
+
import cv2
|
| 431 |
+
import numpy as np
|
| 432 |
+
except ImportError:
|
| 433 |
+
return
|
| 434 |
+
from app.services.qr import qr_png
|
| 435 |
+
|
| 436 |
+
link = "https://t.me/DBUFreshBot?start=dorm5"
|
| 437 |
+
for transparent in (False, True):
|
| 438 |
+
png = qr_png(link, transparent=transparent)
|
| 439 |
+
img = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_UNCHANGED)
|
| 440 |
+
if img.shape[2] == 4: # transparent: put it on white like a printed page
|
| 441 |
+
alpha = img[:, :, 3:4] / 255.0
|
| 442 |
+
img = (img[:, :, :3] * alpha + 255 * (1 - alpha)).astype("uint8")
|
| 443 |
+
text, _, _ = cv2.QRCodeDetector().detectAndDecode(img)
|
| 444 |
+
assert text == link, (transparent, text)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
if __name__ == "__main__":
|
| 448 |
for name, fn in list(globals().items()):
|
| 449 |
if name.startswith("test_"):
|