"""Student contributions: submit -> admin review -> approve (stamp, publish, reward) or reject (explain).""" import logging from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Optional from aiogram import Bot from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core import season from app.core.i18n import t from app.core.notify import safe_send from app.db.models import Material, Submission, User from app.db.repos import library as lib_repo from app.db.repos import submissions as sub_repo from app.db.repos import users as users_repo from app.db.repos import xp as xp_repo from app.services import contrib_rules as K from app.services import library as library_service from app.services import smart_ingest from app.services import xp as xp_service log = logging.getLogger(__name__) # ------------------------------------------------------------------ student side async def check_eligibility(session: AsyncSession, user: User) -> Optional[str]: """None = may submit now, else a reason code (new | blocked | pending | daily).""" cfg = get_settings() now = datetime.now(timezone.utc) rejected = await sub_repo.rejected_times(session, user.id, now - timedelta(days=K.REJECTION_WINDOW_DAYS)) return K.eligibility( active_days=user.active_days or 0, min_days=cfg.contribution_min_days, pending=await sub_repo.pending_count(session, user.id), pending_limit=cfg.contribution_pending_limit, today=await sub_repo.count_since(session, user.id, now - timedelta(hours=24)), daily_limit=cfg.contribution_daily_limit, blocked=K.blocked_until(rejected, now), ) @dataclass class SubmitResult: kind: str # duplicate | accepted | need_course submission: Optional[Submission] = None material: Optional[Material] = None outcome: Optional[smart_ingest.Outcome] = None duplicate_of: Optional[Material] = None suggestions: list = field(default_factory=list) async def submit_file( bot: Bot, session: AsyncSession, user: User, *, file_id: str, unique_id: str, file_name: Optional[str], size: Optional[int], caption: Optional[str], forced_course=None, ) -> SubmitResult: out = await smart_ingest.ingest_file( bot, session, uploader_id=user.id, file_type="document", file_id=file_id, unique_id=unique_id, file_name=file_name, size=size, caption=caption, forced_course=forced_course, backup_channel=0, message=None, stamp=False, # stamped only when an admin approves it ) if out.status == "duplicate": return SubmitResult("duplicate", duplicate_of=out.duplicate_of, outcome=out) material = out.material material.status = "pending" # invisible to students until approved flags = list(out.notes) dup_of = None if out.status == "near_duplicate" and out.duplicate_of is not None: dup_of = out.duplicate_of.id flags.append(f"near-duplicate of #{dup_of} โ€œ{out.duplicate_of.title[:40]}โ€") if material.course_id is None: flags.append("course unknown") sub = await sub_repo.create( session, user_id=user.id, material_id=material.id, file_name=file_name, note="; ".join(flags), dup_of=dup_of ) kind = "need_course" if material.course_id is None else "accepted" return SubmitResult(kind, submission=sub, material=material, outcome=out, suggestions=out.suggestions) async def wanted_courses(session: AsyncSession, user: User, limit: int = 8) -> list[tuple[object, int]]: """Courses (for this student's stream/year) with the fewest files first, only those short of material.""" counts = await lib_repo.file_counts(session) courses = await lib_repo.list_courses(session, user.stream, user.year, 0, 200, only_with_files=False) short = [(c, counts.get(c.id, 0)) for c in courses if counts.get(c.id, 0) < K.SPARSE_COURSE_FILES] short.sort(key=lambda item: (item[1], item[0].id)) return short[:limit] # ------------------------------------------------------------------ admin side @dataclass class Decision: ok: bool text: str async def approve( bot: Bot, session: AsyncSession, sub: Submission, *, replace_id: Optional[int] = None ) -> Decision: cfg = get_settings() material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None if material is None: sub.status, sub.reviewed_at, sub.reason = "rejected", datetime.now(timezone.utc), "gone" return Decision(False, "That file no longer exists.") if material.course_id is None: return Decision(False, "Pick a course first.") submitter = await users_repo.get(session, sub.user_id) files_before = await lib_repo.ok_file_count(session, material.course_id) stamp_note = "" if cfg.watermark_enabled and (material.source_name or "").lower().endswith(".pdf") and not material.watermarked: target = cfg.library_channel_id or (next(iter(cfg.admin_id_set), None) or sub.user_id) ok, reason = await smart_ingest.stamp_existing(bot, material, target) stamp_note = " ยท ๐Ÿ”– stamped" if ok else f" ยท not stamped ({reason})" if replace_id: old = await lib_repo.get_material(session, replace_id) if old: material.downloads = (material.downloads or 0) + (old.downloads or 0) await lib_repo.delete_material(session, old.id) material.status = "ok" if submitter and submitter.credit_name: material.credit = (submitter.full_name or "").strip().split(" ")[0][:40] or None gained = 0 badge_tier = None if submitter: rewarded = await xp_repo.count_kind_in_week(session, submitter.id, "contribution", season.week_key()) xp, bonus = K.reward( rewarded_this_week=rewarded, course_files_before=files_before, xp=cfg.contribution_xp, bonus=cfg.contribution_bonus_xp, weekly_cap=cfg.contribution_weekly_cap, ) main = await xp_service.award(session, submitter, "contribution", xp, ref=f"sub{sub.id}", capped=False) extra = await xp_service.award(session, submitter, "contribution_bonus", bonus, ref=f"subb{sub.id}", capped=False) gained = main.amount + extra.amount session.sync_session.info.setdefault("invalidate", set()).add(submitter.id) sub.status, sub.reviewed_at, sub.xp = "approved", datetime.now(timezone.utc), gained session.sync_session.info["wrote"] = True await session.flush() library_service.invalidate_courses() if submitter: approved_total = await sub_repo.approved_count(session, submitter.id) course = await lib_repo.get_course(session, material.course_id) await safe_send( bot, submitter.id, t(submitter.lang, "contrib_approved", title=material.title, course=course.name if course else "", xp=gained), ) badge_tier = K.badge_for(approved_total) if approved_total in {n for n, _ in K.BADGES} else None if badge_tier: await safe_send(bot, submitter.id, t(submitter.lang, "contrib_badge", badge=t(submitter.lang, f"badge_{badge_tier}"))) return Decision(True, f"โœ… Approved #{material.id} โ€œ{material.title}โ€ (+{gained} XP{stamp_note})") async def reject(bot: Bot, session: AsyncSession, sub: Submission, reason: str) -> Decision: reason = reason if reason in K.REJECT_REASONS else "quality" material = await lib_repo.get_material(session, sub.material_id) if sub.material_id else None title = material.title if material else (sub.file_name or "") if material: await lib_repo.delete_material(session, material.id) now = datetime.now(timezone.utc) sub.status, sub.reason, sub.reviewed_at = "rejected", reason, now session.sync_session.info["wrote"] = True await session.flush() submitter = await users_repo.get(session, sub.user_id) if submitter: text = t(submitter.lang, f"contrib_rejected_{reason}", title=title) rejected = await sub_repo.rejected_times(session, submitter.id, now - timedelta(days=K.REJECTION_WINDOW_DAYS)) if K.blocked_until(rejected, now): text += t(submitter.lang, "contrib_blocked_notice") await safe_send(bot, submitter.id, text) return Decision(True, f"๐Ÿ—‘ Rejected ({reason}): โ€œ{title}โ€")