"""Rules for student contributions. Pure, so every rule is unit-tested.""" import os from datetime import datetime, timedelta from typing import Optional, Sequence ALLOWED_EXT = (".pdf", ".pptx", ".docx") MAX_BYTES = 20 * 1024 * 1024 # the bot can only read files up to 20 MB (needed for duplicate checks + stamping) BLOCK_AFTER_REJECTIONS = 3 REJECTION_WINDOW_DAYS = 14 BLOCK_DAYS = 7 SPARSE_COURSE_FILES = 3 # a course with fewer files than this counts as "wanted" BADGES = ((15, "gold"), (5, "silver"), (1, "bronze")) # approved files needed -> badge REJECT_REASONS = ("duplicate", "quality", "notallowed") def check_file(file_name: Optional[str], size: Optional[int]) -> Optional[str]: """None = fine, else a reason code ('ext' | 'size').""" if os.path.splitext(file_name or "")[1].lower() not in ALLOWED_EXT: return "ext" if size is None or size > MAX_BYTES: return "size" return None def blocked_until(rejected_at: Sequence[datetime], now: datetime) -> Optional[datetime]: """3 rejections inside 14 days -> no new submissions for 7 days after the last one.""" recent = sorted(d for d in rejected_at if d >= now - timedelta(days=REJECTION_WINDOW_DAYS)) if len(recent) < BLOCK_AFTER_REJECTIONS: return None until = recent[-1] + timedelta(days=BLOCK_DAYS) return until if until > now else None def eligibility( *, active_days: int, min_days: int, pending: int, pending_limit: int, today: int, daily_limit: int, blocked: Optional[datetime], ) -> Optional[str]: """None = may submit, else a reason code.""" if active_days < min_days: return "new" if blocked is not None: return "blocked" if pending >= pending_limit: return "pending" if today >= daily_limit: return "daily" return None def badge_for(approved: int) -> Optional[str]: for needed, tier in BADGES: if approved >= needed: return tier return None def next_badge(approved: int) -> Optional[tuple[int, str]]: """(files still needed, tier) for the next badge, or None at the top.""" for needed, tier in reversed(BADGES): if approved < needed: return needed - approved, tier return None def reward( *, rewarded_this_week: int, course_files_before: int, xp: int, bonus: int, weekly_cap: int ) -> tuple[int, int]: """(XP, bonus XP). Nothing once the weekly cap is used; the bonus is for courses that are short of material.""" if rewarded_this_week >= weekly_cap: return 0, 0 return xp, (bonus if course_files_before < SPARSE_COURSE_FILES else 0)