cipher-attacks commited on
Commit
4e64e70
·
1 Parent(s): c3b9247

student contributions + QR

Browse files
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 &lt;id&gt; [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_"):