Dbu-central-bot / app /services /contributions.py
cipher-attacks's picture
student contributions + QR
4e64e70
Raw History Blame Contribute Delete
8.38 kB
"""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}”")