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