diff --git a/backend/contributions/management/commands/review_submissions.py b/backend/contributions/management/commands/review_submissions.py new file mode 100644 index 00000000..fad0e23d --- /dev/null +++ b/backend/contributions/management/commands/review_submissions.py @@ -0,0 +1,1126 @@ +""" +AI-assisted submission review management command. + +Processes pending submissions through two tiers: + Tier 1: Deterministic rules (auto-reject with 100% confidence) + Tier 2: AI classification via OpenRouter/Claude (propose for human review) + +Usage: + # Dry run - see what would happen without making changes + python manage.py review_submissions --dry-run + + # Tier 1 only - deterministic rules + python manage.py review_submissions --tier1-only + + # Tier 2 only - AI classification (skips tier 1) + python manage.py review_submissions --tier2-only + + # Full run with batch size + python manage.py review_submissions --batch-size 50 + + # Filter by contribution type + python manage.py review_submissions --type "Educational Content" + + # Process a specific submission + python manage.py review_submissions --submission-id + +Environment: + OPENROUTER_API_KEY: Required for Tier 2 AI classification +""" + +import json +import logging +import os +import re +import time +from collections import defaultdict + +import requests as http_requests +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.utils import timezone + +from contributions.models import ( + ContributionType, + SubmissionNote, + SubmittedContribution, +) +from stewards.models import ReviewTemplate, Steward, StewardPermission +from users.models import User + +logger = logging.getLogger(__name__) + +AI_STEWARD_EMAIL = 'genlayer-steward@genlayer.foundation' +AI_STEWARD_NAME = 'GenLayer Steward' + +OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions' +OPENROUTER_MODEL = 'anthropic/claude-opus-4.6' + +# Pricing per million tokens (USD) - for cost tracking +MODEL_PRICING = { + 'anthropic/claude-opus-4.6': {'input': 5.0, 'output': 25.0}, + 'anthropic/claude-sonnet-4.6': {'input': 3.0, 'output': 15.0}, + 'anthropic/claude-haiku-4.5': {'input': 1.0, 'output': 5.0}, +} + + +# ─── Tier 1 Rules ──────────────────────────────────────────────────────────── +# Each rule returns (template_label, crm_reason) or None if it doesn't match. +# Rules are evaluated in order; first match wins. + +SPAM_NOTES = { + 'nothing', 'good', 'ok', 'okey', 'yes', 'no', 'gm', 'gn', 'lfg', + 'vvv', 'ccc', 'bio', 'bye', 'sexy girl', 'fgn', 'cxcx', 'hi', + 'hello', 'test', 'airdrop', 'free', +} + + +def rule_no_evidence_no_notes(submission, evidence_items): + """Empty submission: no evidence AND no meaningful notes.""" + has_evidence = len(evidence_items) > 0 + notes = (submission.notes or '').strip() + if not has_evidence and len(notes) == 0: + return ( + 'Reject: No Evidence', + 'Tier 1 auto-reject: No evidence and no notes provided.', + ) + return None + + +def rule_no_evidence_short_notes(submission, evidence_items): + """No evidence with very short notes (< 10 chars).""" + has_evidence = len(evidence_items) > 0 + notes = (submission.notes or '').strip() + if not has_evidence and 0 < len(notes) <= 10: + return ( + 'Reject: No Evidence', + f'Tier 1 auto-reject: No evidence and notes too short ' + f'({len(notes)} chars): "{notes}"', + ) + return None + + +def rule_spam_notes(submission, evidence_items): + """Notes match known spam/gibberish patterns.""" + notes = (submission.notes or '').strip().lower() + if notes in SPAM_NOTES: + return ( + 'Reject: Unintelligible Notes', + f'Tier 1 auto-reject: Notes match spam pattern: "{notes}"', + ) + # Check for purely numeric / random character notes + if len(notes) <= 15 and re.match(r'^[^a-zA-Z]*$', notes) and len(notes) > 0: + return ( + 'Reject: Unintelligible Notes', + f'Tier 1 auto-reject: Notes are non-alphabetic gibberish: "{notes}"', + ) + return None + + +def rule_duplicate_pending_from_same_user(submission, evidence_items): + """Same user already has an older pending submission with identical notes. + + When duplicates exist, keep the one with the most evidence. + If the current submission has evidence but the older duplicate doesn't, + skip rejecting this one (the evidence-less duplicate will be caught + when processed separately). + """ + notes = (submission.notes or '').strip() + if not notes: + return None # Handled by other rules + + from contributions.models import Evidence + + # Find older pending submissions with the same notes + older_duplicates = ( + SubmittedContribution.objects + .filter( + user=submission.user, + notes=submission.notes, + state='pending', + created_at__lt=submission.created_at, + ) + ) + if not older_duplicates.exists(): + return None + + # Check if the current submission has evidence + current_has_evidence = len(evidence_items) > 0 + + if current_has_evidence: + # This submission has evidence — check if ALL older duplicates also + # have evidence. If any older duplicate lacks evidence, don't reject + # this one (the better submission should survive). + for older in older_duplicates: + older_has_evidence = Evidence.objects.filter( + submitted_contribution=older, + url__gt='', + ).exists() + if not older_has_evidence: + # Older duplicate has no evidence but we do — skip rejection + return None + + return ( + 'Reject: Duplicate Submission', + f'Tier 1 auto-reject: Duplicate of an older pending submission ' + f'from the same user with identical notes.', + ) + + +def rule_resubmitted_rejected_url(submission, evidence_items): + """Same user resubmits a URL that was already rejected.""" + if not evidence_items: + return None + + urls = [e.url for e in evidence_items if e.url] + if not urls: + return None + + from contributions.models import Evidence + # Check if any URL was previously rejected for this same user + rejected_url = ( + Evidence.objects + .filter( + url__in=urls, + submitted_contribution__user=submission.user, + submitted_contribution__state='rejected', + ) + .values_list('url', flat=True) + .first() + ) + if rejected_url: + return ( + 'Reject: Duplicate Submission', + f'Tier 1 auto-reject: URL was previously rejected for this user: ' + f'{rejected_url[:100]}', + ) + return None + + +def rule_same_url_reused_by_same_user(submission, evidence_items): + """Same user submitted the same URL in another pending submission.""" + if not evidence_items: + return None + + urls = [e.url for e in evidence_items if e.url] + if not urls: + return None + + from contributions.models import Evidence + # Check if any URL appears in an older pending submission by this user + older_with_same_url = ( + Evidence.objects + .filter( + url__in=urls, + submitted_contribution__user=submission.user, + submitted_contribution__state='pending', + submitted_contribution__created_at__lt=submission.created_at, + ) + .exclude(submitted_contribution=submission) + .values_list('url', flat=True) + .first() + ) + if older_with_same_url: + return ( + 'Reject: Duplicate Submission', + f'Tier 1 auto-reject: Same URL already used in an older pending ' + f'submission by this user: {older_with_same_url[:100]}', + ) + return None + + +# Generic platform URL prefixes that are not valid evidence +BLOCKLISTED_URL_PREFIXES = [ + 'https://studio.genlayer.com/run-debug', + 'https://studio.genlayer.com/contracts', + 'https://points.genlayer.foundation', + 'https://www.genlayer.com', + 'https://genlayer.com', +] + + +def _is_blocklisted_url(url): + """Check if URL matches a blocklisted platform prefix.""" + normalized = url.split('?')[0].split('#')[0].rstrip('/') + for prefix in BLOCKLISTED_URL_PREFIXES: + if normalized == prefix or normalized.startswith(prefix + '/'): + return True + return False + + +def rule_blocklisted_evidence_url(submission, evidence_items): + """Evidence URL is a generic platform page, not actual work.""" + if not evidence_items: + return None + + urls = [e.url for e in evidence_items if e.url] + blocklisted_urls = [u for u in urls if _is_blocklisted_url(u)] + if blocklisted_urls and len(blocklisted_urls) == len(urls): + # ALL evidence URLs are blocklisted + return ( + 'Reject: No Evidence', + f'Tier 1 auto-reject: Evidence URL is a generic platform ' + f'page, not proof of work: {blocklisted_urls[0][:100]}', + ) + return None + + +def rule_cross_user_duplicate_url(submission, evidence_items): + """Evidence URL already submitted by a different user (copy-paste gaming).""" + if not evidence_items: + return None + + urls = [e.url for e in evidence_items if e.url] + if not urls: + return None + + from contributions.models import Evidence + # Skip GitHub repos (can be legitimately shared/forked) and blocklisted URLs + non_repo_urls = [ + u for u in urls + if not re.match(r'https?://github\.com/[^/]+/[^/]+/?$', u) + and not _is_blocklisted_url(u) + ] + if not non_repo_urls: + return None + + # Check if the same URL was submitted by a different user + other_user_url = ( + Evidence.objects + .filter( + url__in=non_repo_urls, + submitted_contribution__state__in=['pending', 'accepted'], + ) + .exclude(submitted_contribution__user=submission.user) + .values_list('url', flat=True) + .first() + ) + if other_user_url: + return ( + 'Reject: Duplicate Submission', + f'Tier 1 auto-reject: Evidence URL was already submitted by ' + f'another user (copy-paste gaming): {other_user_url[:100]}', + ) + return None + + +def rule_cross_user_identical_notes(submission, evidence_items): + """Exact same notes (>20 chars) used by a different user — farming template.""" + notes = (submission.notes or '').strip() + if len(notes) <= 20: + return None + + # Skip system-generated notes + if notes.startswith('Automatic submission'): + return None + + other_user = ( + SubmittedContribution.objects + .filter(notes=submission.notes) + .exclude(user=submission.user) + .values_list('user__email', flat=True) + .first() + ) + if other_user: + return ( + 'Reject: Duplicate Submission', + f'Tier 1 auto-reject: Identical notes used by another user ' + f'(farming template): {other_user}', + ) + return None + + +TIER1_RULES = [ + rule_no_evidence_no_notes, + rule_no_evidence_short_notes, + rule_spam_notes, + rule_duplicate_pending_from_same_user, + rule_resubmitted_rejected_url, + rule_same_url_reused_by_same_user, + rule_blocklisted_evidence_url, + rule_cross_user_duplicate_url, + rule_cross_user_identical_notes, +] + + +# ─── Tier 2 (AI via OpenRouter) ────────────────────────────────────────────── + +def build_system_prompt(templates): + """Build the system prompt for the AI reviewer.""" + template_list = '\n'.join( + f' - ID {t.id}: "{t.label}" → {t.text[:120]}...' + if len(t.text) > 120 else f' - ID {t.id}: "{t.label}" → {t.text}' + for t in templates + ) + + return f"""You are an AI reviewer for the GenLayer Testnet Program submission system. + +Your job is to evaluate community submissions and propose a review action. + +## Context +GenLayer is an AI-native blockchain. The testnet program rewards users for meaningful +contributions like building tools, writing educational content, running validators, +creating documentation, and community building. + +## Available Review Templates +{template_list} + +## Evaluation Criteria +1. **Evidence quality**: Does the submission include verifiable evidence (GitHub repos, + blog posts, deployed apps)? Is the evidence real and substantial? +2. **Contribution value**: Does this meaningfully advance the GenLayer ecosystem? + Simple social media mentions with no depth are low-value. +3. **Originality**: Is this original work or copied/auto-generated content? +4. **Correct categorization**: Did the user select the right contribution type? + This is critical. Non-technical tweets and social posts submitted as "Educational + Content", "Documentation", or "Research & Analysis" should be rejected with the + "Not for Builders: Community Content" template. Builder categories require + in-depth technical work — community content should be submitted through Discord + to earn XP. +5. **Duplication**: Has this same work been submitted before by this user? + +## Confidence Levels +- HIGH: You are very certain about the action (obvious spam, clearly valuable, etc.) +- MEDIUM: Likely correct but a human should verify +- LOW: Ambiguous, needs human judgment + +## Non-Technical Community Content (NOT for Builders) +This is the MOST COMMON mistake. Many users submit simple social media posts or +non-technical overviews under "Educational Content", "Documentation", or "Research +& Analysis" (Builder category). This content is NOT for builders or validators — +it's community engagement that should be submitted through Discord to earn XP +(a dedicated Community section on the platform is coming soon). + +**Reject with "Not for Builders: Community Content" template when:** +- A tweet or X post talks about GenLayer in general terms without technical depth +- A post is a "beginner guide" or "how to get started" tweet (not a full tutorial) +- Content is a conceptual overview, awareness post, or promotional thread +- A blog post summarizes what GenLayer is without code examples or technical analysis +- The submission describes GenLayer features at a surface level + +**Keep as Educational Content / Documentation / Research when:** +- The content has actual code snippets, smart contract examples, or technical detail +- It's a full blog post or article (not just a tweet) with in-depth technical analysis +- It includes a GitHub repo with real implementation +- The tweet thread is very substantial (10+ tweets) with genuine technical walkthrough + +When rejecting for this reason, use the "Not for Builders: Community Content" +template and include the `wrong_type` flag. The staff_reply should tell the user +to submit through Discord to earn XP for community content, and encourage them +to submit technical content for the Builder category. + +## Rejection Guidelines (be firm) +- **Tweets/X posts as Educational Content**: A single tweet or short thread about + GenLayer is NOT "Educational Content", "Documentation", or "Research & Analysis". + These should be rejected with the "Not for Builders: Community Content" template. + Do NOT just reject with "Too Superficial" — tell them to submit through Discord + to earn XP for community content, and that the Builder section needs technical depth. +- **No code, no evidence**: Submissions describing what they *want* to build or *plan* to + build, without any actual work done, should be rejected. +- **Referral promotion**: Tweets promoting referral codes are never accepted. +- **Generic overviews**: Blog posts or articles that are generic blockchain overviews + with GenLayer name-dropped are not valuable. +- **AI-generated content**: Content clearly written by AI (generic phrasing, no specific + details, follows obvious ChatGPT patterns) should be flagged and rejected. + +## Acceptance Guidelines (be fair) +- **GitHub repos with real code**: Projects with actual implementation, README, and + ideally a deployed demo are valuable. Award points based on complexity and quality. +- **Deep technical content**: Blog posts or articles with specific technical details + about GenLayer's architecture, hands-on tutorials, or detailed comparisons. +- **Tools and infrastructure**: Anything that helps the ecosystem grow — dashboards, + testing tools, deployment utilities, SDKs. +- **Original research**: Analysis that provides genuine insights, not surface-level + descriptions. +- Award MINIMAL points (1-2) for borderline acceptable content. +- Award HIGHER points (3-5+) for substantial, high-quality work. + +## Important Notes +- Never auto-accept. Acceptances always need human review. +- Users with many prior rejections and zero acceptances are likely spammers. +- Users with prior acceptances are more likely to be legitimate contributors. +- If notes describe a project but evidence is missing, prefer "more_info" over "reject" + (give them a chance to provide links). But if the user has a history of spam, reject. +- Always include a staff_reply that is helpful and specific to the submission. + +Respond ONLY with valid JSON matching the required schema.""" + + +def build_few_shot_examples(contribution_type_id): + """Build few-shot examples from historical reviewed submissions of the same type.""" + examples = [] + + # Get accepted examples for this type + accepted = ( + SubmittedContribution.objects + .filter( + contribution_type_id=contribution_type_id, + state='accepted', + ) + .select_related('contribution_type', 'user', 'converted_contribution') + .prefetch_related('evidence_items') + .order_by('-reviewed_at')[:5] + ) + + for sub in accepted: + evidence_urls = [e.url for e in sub.evidence_items.all() if e.url] + points = ( + sub.converted_contribution.points + if sub.converted_contribution else None + ) + examples.append({ + 'role': 'accepted', + 'notes': (sub.notes or '')[:300], + 'evidence_urls': evidence_urls[:3], + 'staff_reply': (sub.staff_reply or '')[:200], + 'points': points, + }) + + # Get rejected examples for this type + rejected = ( + SubmittedContribution.objects + .filter( + contribution_type_id=contribution_type_id, + state='rejected', + ) + .select_related('contribution_type') + .prefetch_related('evidence_items') + .order_by('-reviewed_at')[:5] + ) + + for sub in rejected: + evidence_urls = [e.url for e in sub.evidence_items.all() if e.url] + examples.append({ + 'role': 'rejected', + 'notes': (sub.notes or '')[:300], + 'evidence_urls': evidence_urls[:3], + 'staff_reply': (sub.staff_reply or '')[:200], + }) + + return examples + + +def build_user_message(submission, evidence_items, user_history): + """Build the user message for a single submission.""" + evidence_data = [] + for e in evidence_items: + item = {} + if e.url: + item['url'] = e.url + if e.description: + item['description'] = e.description[:200] + if item: + evidence_data.append(item) + + # Build few-shot examples for this contribution type + examples = build_few_shot_examples(submission.contribution_type_id) + examples_str = '' + if examples: + examples_str = ( + f'\n\n## Historical Examples for ' + f'"{submission.contribution_type.name}"\n' + ) + for i, ex in enumerate(examples, 1): + examples_str += f'\nExample {i} ({ex["role"]}):\n' + examples_str += f' Notes: {ex["notes"][:200]}\n' + examples_str += ( + f' Evidence: {", ".join(ex.get("evidence_urls", []))}\n' + ) + if ex.get('staff_reply'): + examples_str += f' Staff reply: {ex["staff_reply"][:150]}\n' + if ex.get('points'): + examples_str += f' Points awarded: {ex["points"]}\n' + + category_name = ( + submission.contribution_type.category.name + if submission.contribution_type.category else 'N/A' + ) + + return f"""## Submission to Review +- **Type**: {submission.contribution_type.name} (Category: {category_name}) +- **Notes**: {(submission.notes or '(empty)')[:500]} +- **Evidence**: {json.dumps(evidence_data) if evidence_data else '(none)'} +- **Submitted**: {submission.created_at.strftime('%Y-%m-%d')} + +## User History +- Accepted submissions: {user_history['accepted']} +- Rejected submissions: {user_history['rejected']} +- Pending submissions: {user_history['pending']} +- Rejection rate: {user_history['rejection_rate']}%{' ⚠️ HIGH REJECTION RATE' if user_history['rejection_rate'] >= 80 and user_history['total_reviewed'] >= 3 else ''} +{'- Spam signals: ' + '; '.join(user_history.get('spam_signals', [])) if user_history.get('spam_signals') else ''} + +## Contribution Type Constraints +- Min points: {submission.contribution_type.min_points} +- Max points: {submission.contribution_type.max_points} +{examples_str} + +Please evaluate this submission and respond with JSON: +{{ + "action": "accept" | "reject" | "more_info", + "template_id": , + "staff_reply": "", + "points": , + "confidence": "high" | "medium" | "low", + "reasoning": "", + "flags": [""] +}}""" + + +def call_openrouter(system_prompt, user_message, model=None): + """Call the OpenRouter API and return (parsed_json, usage_dict).""" + api_key = os.environ.get('OPENROUTER_API_KEY') + if not api_key: + raise ValueError( + 'OPENROUTER_API_KEY environment variable is required for Tier 2. ' + 'Set it in your .env file.' + ) + + model = model or OPENROUTER_MODEL + + response = http_requests.post( + OPENROUTER_API_URL, + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://points.genlayer.foundation', + 'X-Title': 'GenLayer Submission Review', + }, + json={ + 'model': model, + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_message}, + ], + 'max_tokens': 1024, + 'temperature': 0.1, + }, + timeout=120, + ) + + if response.status_code != 200: + raise ValueError( + f'OpenRouter API error {response.status_code}: {response.text[:500]}' + ) + + data = response.json() + + # Extract usage + usage = data.get('usage', {}) + usage_info = { + 'input_tokens': usage.get('prompt_tokens', 0), + 'output_tokens': usage.get('completion_tokens', 0), + 'model': model, + } + + # Calculate cost + pricing = MODEL_PRICING.get(model, {'input': 0, 'output': 0}) + usage_info['cost_usd'] = ( + usage_info['input_tokens'] * pricing['input'] / 1_000_000 + + usage_info['output_tokens'] * pricing['output'] / 1_000_000 + ) + + # Parse response content + text = data['choices'][0]['message']['content'].strip() + # Handle markdown code blocks + if text.startswith('```'): + text = text.split('\n', 1)[1].rsplit('```', 1)[0].strip() + + # Try to extract JSON from the response if direct parse fails + try: + result = json.loads(text) + except json.JSONDecodeError: + # Try to find JSON object in the text + match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, re.DOTALL) + if match: + result = json.loads(match.group()) + else: + raise + + return result, usage_info + + +def classify_with_ai(submission, evidence_items, user_history, templates, + model=None, max_retries=2): + """Classify a submission using the AI via OpenRouter.""" + system_prompt = build_system_prompt(templates) + user_message = build_user_message(submission, evidence_items, user_history) + + last_error = None + total_usage = {'input_tokens': 0, 'output_tokens': 0, 'cost_usd': 0.0} + for attempt in range(max_retries + 1): + try: + result, usage = call_openrouter( + system_prompt, user_message, model=model, + ) + # Accumulate cost across retries + total_usage['input_tokens'] += usage.get('input_tokens', 0) + total_usage['output_tokens'] += usage.get('output_tokens', 0) + total_usage['cost_usd'] += usage.get('cost_usd', 0.0) + total_usage['model'] = usage.get('model') + + # Validate required fields + required = ['action', 'template_id', 'confidence', 'reasoning'] + for field in required: + if field not in result: + raise ValueError( + f'AI response missing required field: {field}' + ) + + if result['action'] not in ('accept', 'reject', 'more_info'): + raise ValueError(f'Invalid action: {result["action"]}') + + if result['confidence'] not in ('high', 'medium', 'low'): + raise ValueError( + f'Invalid confidence: {result["confidence"]}' + ) + + result['_usage'] = total_usage + return result + except (json.JSONDecodeError, ValueError, KeyError) as e: + last_error = e + if attempt < max_retries: + time.sleep(1) # Brief pause before retry + continue + + raise last_error + + +# ─── Command ───────────────────────────────────────────────────────────────── + +class Command(BaseCommand): + help = 'Review pending submissions using deterministic rules and AI' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would happen without making changes', + ) + parser.add_argument( + '--tier1-only', + action='store_true', + help='Only run Tier 1 deterministic rules', + ) + parser.add_argument( + '--tier2-only', + action='store_true', + help='Only run Tier 2 AI classification (skip Tier 1)', + ) + parser.add_argument( + '--batch-size', + type=int, + default=0, + help='Max submissions to process (0 = all)', + ) + parser.add_argument( + '--type', + type=str, + help='Only process submissions of this contribution type name', + ) + parser.add_argument( + '--submission-id', + type=str, + help='Process a specific submission by UUID', + ) + parser.add_argument( + '--model', + type=str, + default=None, + help=( + 'Override AI model ' + '(e.g., anthropic/claude-sonnet-4-20250514)' + ), + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + tier1_only = options['tier1_only'] + tier2_only = options['tier2_only'] + batch_size = options['batch_size'] + model_override = options.get('model') + + if dry_run: + self.stdout.write(self.style.WARNING('=== DRY RUN MODE ===')) + + # Ensure AI steward exists + ai_user = self.ensure_ai_steward() + + # Load templates + templates = {t.label: t for t in ReviewTemplate.objects.all()} + if not templates: + self.stdout.write(self.style.ERROR( + 'No review templates found. Please create them first.' + )) + return + + # Build queryset + qs = SubmittedContribution.objects.filter( + state__in=['pending', 'more_info_needed'], + ).select_related( + 'contribution_type', + 'contribution_type__category', + 'user', + ).prefetch_related('evidence_items') + + if options['submission_id']: + qs = qs.filter(id=options['submission_id']) + if options['type']: + qs = qs.filter(contribution_type__name=options['type']) + + # Don't re-process submissions already reviewed/proposed by AI + qs = qs.filter( + Q(proposed_by__isnull=True) | ~Q(proposed_by=ai_user), + ).exclude(reviewed_by=ai_user) + + if batch_size > 0: + qs = qs[:batch_size] + + submissions = list(qs) + self.stdout.write(f'Found {len(submissions)} submissions to process') + + # Stats & cost tracking + stats = defaultdict(int) + total_cost = 0.0 + total_input_tokens = 0 + total_output_tokens = 0 + + for i, submission in enumerate(submissions, 1): + evidence_items = list(submission.evidence_items.all()) + self.stdout.write( + f'\n[{i}/{len(submissions)}] {submission.id} ' + f'| {submission.contribution_type.name} ' + f'| evidence: {len(evidence_items)} ' + f'| notes: {len((submission.notes or ""))}chars' + ) + + # ── Tier 1: Deterministic rules ── + if not tier2_only: + tier1_result = self.run_tier1( + submission, evidence_items, templates, + ) + if tier1_result: + template, crm_reason = tier1_result + stats['tier1_reject'] += 1 + self.stdout.write(self.style.WARNING( + f' -> Tier 1 REJECT: {template.label}' + )) + if not dry_run: + self.apply_direct_reject( + submission, ai_user, template, crm_reason, + ) + continue + + # ── Tier 2: AI classification ── + if not tier1_only: + tier2_result = self.run_tier2( + submission, evidence_items, ai_user, templates, + dry_run, model_override, + ) + if tier2_result: + action = tier2_result['action'] + confidence = tier2_result['confidence'] + stats[f'tier2_{action}_{confidence}'] += 1 + + usage = tier2_result.get('_usage', {}) + total_cost += usage.get('cost_usd', 0) + total_input_tokens += usage.get('input_tokens', 0) + total_output_tokens += usage.get('output_tokens', 0) + + if tier1_only: + stats['tier1_pass'] += 1 + + # ── Auto-ban check ── + banned_count = self.check_auto_bans(ai_user, dry_run) + stats['auto_banned'] = banned_count + + # ── Print summary ── + self.stdout.write('\n' + '=' * 60) + self.stdout.write(self.style.SUCCESS('SUMMARY')) + for key, count in sorted(stats.items()): + if count > 0: + self.stdout.write(f' {key}: {count}') + + if total_cost > 0: + self.stdout.write(f'\n API Usage:') + self.stdout.write(f' Input tokens: {total_input_tokens:,}') + self.stdout.write(f' Output tokens: {total_output_tokens:,}') + self.stdout.write(f' Total cost: ${total_cost:.4f}') + + if dry_run: + self.stdout.write(self.style.WARNING( + '\nDry run complete. No changes made.' + )) + + def ensure_ai_steward(self): + """Get or create the AI steward user.""" + user, created = User.objects.get_or_create( + email=AI_STEWARD_EMAIL, + defaults={ + 'name': AI_STEWARD_NAME, + 'visible': False, + }, + ) + if created: + user.set_unusable_password() + user.save() + self.stdout.write(self.style.SUCCESS( + f'Created AI steward user: {user.email}' + )) + + # Ensure steward profile + steward, created = Steward.objects.get_or_create(user=user) + if created: + self.stdout.write(self.style.SUCCESS( + f'Created steward profile for {user.email}' + )) + + # Ensure permissions for all contribution types and all actions + actions = ['propose', 'accept', 'reject', 'request_more_info'] + for ct in ContributionType.objects.all(): + for action in actions: + StewardPermission.objects.get_or_create( + steward=steward, + contribution_type=ct, + action=action, + ) + + return user + + def check_auto_bans(self, ai_user, dry_run): + """ + Auto-ban users who meet the threshold: + 5+ total rejections AND 0 acceptances (100% rejection rate). + Only bans users who aren't already banned. + """ + from django.db.models import Count, Q + + # Find users with 5+ rejections and 0 acceptances, not already banned + candidates = ( + User.objects + .filter(is_banned=False) + .exclude(email=AI_STEWARD_EMAIL) + .annotate( + total_rejected=Count( + 'submitted_contributions', + filter=Q(submitted_contributions__state='rejected'), + ), + total_accepted=Count( + 'submitted_contributions', + filter=Q(submitted_contributions__state='accepted'), + ), + ) + .filter(total_rejected__gte=5, total_accepted=0) + ) + + banned_count = 0 + for user in candidates: + self.stdout.write(self.style.WARNING( + f' AUTO-BAN: {user.email} ' + f'({user.total_rejected} rejections, 0 acceptances)' + )) + if not dry_run: + user.is_banned = True + user.ban_reason = ( + f'Auto-banned: {user.total_rejected} consecutive ' + f'rejections with no accepted contributions.' + ) + user.banned_at = timezone.now() + user.banned_by = ai_user + user.save() + banned_count += 1 + + if banned_count > 0: + self.stdout.write(self.style.WARNING( + f'\n {banned_count} user(s) {"would be" if dry_run else ""} ' + f'auto-banned.' + )) + return banned_count + + def run_tier1(self, submission, evidence_items, templates): + """Run Tier 1 deterministic rules. Returns (template, crm_reason) or None.""" + for rule_fn in TIER1_RULES: + result = rule_fn(submission, evidence_items) + if result: + template_label, crm_reason = result + template = templates.get(template_label) + if template is None: + self.stdout.write(self.style.ERROR( + f' Template not found: {template_label}' + )) + continue + return template, crm_reason + return None + + def apply_direct_reject(self, submission, ai_user, template, crm_reason): + """Apply a direct rejection (100% confidence Tier 1).""" + submission.state = 'rejected' + submission.staff_reply = template.text + submission.reviewed_by = ai_user + submission.reviewed_at = timezone.now() + # Clear any existing proposal fields + submission.proposed_action = None + submission.proposed_points = None + submission.proposed_contribution_type = None + submission.proposed_user = None + submission.proposed_staff_reply = '' + submission.proposed_create_highlight = False + submission.proposed_highlight_title = '' + submission.proposed_highlight_description = '' + submission.proposed_by = None + submission.proposed_at = None + submission.save() + + SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message=crm_reason, + is_proposal=False, + data={ + 'action': 'reject', + 'points': None, + 'staff_reply': template.text, + 'template_id': template.id, + 'confidence': 'high', + 'flags': [], + 'reasoning': crm_reason, + }, + ) + + def _compute_spam_signals(self, submission, evidence_items): + """Compute additional spam signals for AI context.""" + signals = [] + from contributions.models import Evidence + + urls = [e.url for e in evidence_items if e.url] + + # Daily submission velocity + from django.db.models.functions import TruncDate + from django.db.models import Count + if submission.created_at: + same_day = ( + SubmittedContribution.objects + .filter( + user=submission.user, + created_at__date=submission.created_at.date(), + ) + .count() + ) + if same_day >= 5: + signals.append( + f'HIGH VELOCITY: {same_day} submissions on same day' + ) + + # Check for "airdrop" in user email + email = submission.user.email.lower() + if 'airdrop' in email: + signals.append('SUSPICIOUS EMAIL: contains "airdrop"') + + # Check total pending count (shotgun submitter) + pending_count = ( + SubmittedContribution.objects + .filter(user=submission.user, state='pending') + .count() + ) + if pending_count >= 8: + distinct_types = ( + SubmittedContribution.objects + .filter(user=submission.user, state='pending') + .values('contribution_type') + .distinct() + .count() + ) + signals.append( + f'BULK SUBMITTER: {pending_count} pending across ' + f'{distinct_types} types' + ) + + return signals + + def run_tier2(self, submission, evidence_items, ai_user, templates, + dry_run, model_override=None): + """Run Tier 2 AI classification. Returns the AI result dict or None.""" + # Get user history + accepted = SubmittedContribution.objects.filter( + user=submission.user, state='accepted', + ).count() + rejected = SubmittedContribution.objects.filter( + user=submission.user, state='rejected', + ).count() + pending = SubmittedContribution.objects.filter( + user=submission.user, state='pending', + ).count() + total_reviewed = accepted + rejected + rejection_rate = ( + round(rejected * 100 / total_reviewed) if total_reviewed > 0 + else 0 + ) + user_history = { + 'accepted': accepted, + 'rejected': rejected, + 'pending': pending, + 'total_reviewed': total_reviewed, + 'rejection_rate': rejection_rate, + 'spam_signals': self._compute_spam_signals( + submission, evidence_items, + ), + } + + try: + template_objects = list(ReviewTemplate.objects.all()) + result = classify_with_ai( + submission, evidence_items, user_history, template_objects, + model=model_override, + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f' -> AI error: {e}')) + return None + + action = result['action'] + confidence = result['confidence'] + reasoning = result.get('reasoning', '') + flags = result.get('flags', []) + points = result.get('points') + staff_reply = result.get('staff_reply', '') + usage = result.get('_usage', {}) + + flag_str = f' [{", ".join(flags)}]' if flags else '' + cost_str = f' (${usage.get("cost_usd", 0):.4f})' if usage else '' + self.stdout.write( + f' -> AI: {action} (confidence: {confidence}){flag_str}{cost_str}' + ) + self.stdout.write(f' Reasoning: {reasoning[:150]}') + + if dry_run: + return result + + # All Tier 2 results are proposals for human review + # (only Tier 1 deterministic rules auto-reject) + submission.proposed_action = action + submission.proposed_points = points if action == 'accept' else None + submission.proposed_staff_reply = staff_reply + submission.proposed_by = ai_user + submission.proposed_at = timezone.now() + submission.save() + + crm_msg = ( + f'AI proposal ({confidence} confidence): {action}\n' + f'Reasoning: {reasoning}\n' + f'Flags: {", ".join(flags) if flags else "none"}' + ) + SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message=crm_msg, + is_proposal=True, + data={ + 'action': action, + 'points': points, + 'staff_reply': staff_reply, + 'template_id': result.get('template_id'), + 'confidence': confidence, + 'flags': flags, + 'reasoning': reasoning, + }, + ) + + return result diff --git a/backend/contributions/migrations/0033_submissionnote_data_field.py b/backend/contributions/migrations/0033_submissionnote_data_field.py new file mode 100644 index 00000000..6137f1c1 --- /dev/null +++ b/backend/contributions/migrations/0033_submissionnote_data_field.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.2 on 2026-02-26 17:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0032_submittedcontribution_proposal_fields_submissionnote'), + ] + + operations = [ + migrations.AlterModelOptions( + name='submissionnote', + options={'ordering': ['-created_at']}, + ), + migrations.AddField( + model_name='submissionnote', + name='data', + field=models.JSONField(blank=True, default=dict, help_text='Structured data: action, points, staff_reply, template_id, flags, confidence'), + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index d7458134..3568f42c 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -406,6 +406,11 @@ class SubmissionNote(BaseModel): default=False, help_text="True for auto-generated proposal notes" ) + data = models.JSONField( + default=dict, + blank=True, + help_text="Structured data: action, points, staff_reply, template_id, flags, confidence" + ) class Meta: ordering = ['-created_at'] diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index a6b3f948..e6125923 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -467,6 +467,9 @@ class StewardSubmissionReviewSerializer(serializers.Serializer): # Staff reply (required for reject/more_info) staff_reply = serializers.CharField(required=False, allow_blank=True) + + # Template tracking for calibration + template_id = serializers.IntegerField(required=False, allow_null=True) def validate(self, data): """Validate the review action and required fields.""" @@ -514,8 +517,8 @@ class SubmissionNoteSerializer(serializers.ModelSerializer): class Meta: model = SubmissionNote - fields = ['id', 'user', 'user_name', 'message', 'is_proposal', 'created_at'] - read_only_fields = ['id', 'user', 'user_name', 'is_proposal', 'created_at'] + fields = ['id', 'user', 'user_name', 'message', 'is_proposal', 'data', 'created_at'] + read_only_fields = ['id', 'user', 'user_name', 'is_proposal', 'data', 'created_at'] def get_user_name(self, obj): return obj.user.name or obj.user.address[:10] + '...' @@ -536,6 +539,8 @@ class SubmissionProposeSerializer(serializers.Serializer): required=False, ) proposed_staff_reply = serializers.CharField(required=False, allow_blank=True, default='') + # Template tracking for calibration + template_id = serializers.IntegerField(required=False, allow_null=True) proposed_create_highlight = serializers.BooleanField(default=False, required=False) proposed_highlight_title = serializers.CharField(max_length=200, required=False, allow_blank=True, default='') proposed_highlight_description = serializers.CharField(required=False, allow_blank=True, default='') diff --git a/backend/contributions/tests/test_ban_submission_block.py b/backend/contributions/tests/test_ban_submission_block.py new file mode 100644 index 00000000..4f3fc9f5 --- /dev/null +++ b/backend/contributions/tests/test_ban_submission_block.py @@ -0,0 +1,98 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework.test import APIClient +from rest_framework import status + +from contributions.models import SubmittedContribution, ContributionType, Category + +User = get_user_model() + + +class BannedUserSubmissionBlockTest(TestCase): + """Test that banned users cannot create new submissions.""" + + def setUp(self): + self.category = Category.objects.create( + name='Test Category', slug='test', description='Test', + ) + self.contribution_type = ContributionType.objects.create( + name='Test Type', slug='test-type', + description='Test contribution type', + category=self.category, min_points=1, max_points=100, + ) + self.user = User.objects.create_user( + email='user@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.client = APIClient() + + def test_normal_user_can_submit(self): + """Non-banned user can create a submission.""" + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/submissions/', { + 'contribution_type': self.contribution_type.id, + 'contribution_date': timezone.now().date().isoformat(), + 'notes': 'My great contribution', + 'recaptcha': 'test-token', + }, format='json') + # Should not be 403 (may be 201 or 400 depending on recaptcha config) + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_banned_user_cannot_submit(self): + """Banned user gets 403 when trying to submit.""" + self.user.is_banned = True + self.user.ban_reason = 'Repeated spam' + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/submissions/', { + 'contribution_type': self.contribution_type.id, + 'contribution_date': timezone.now().date().isoformat(), + 'notes': 'Trying to submit while banned', + 'recaptcha': 'test-token', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('suspended', response.data['error']) + + def test_banned_user_error_message_mentions_appeal(self): + """The 403 error message tells the user about the appeal option.""" + self.user.is_banned = True + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/submissions/', { + 'contribution_type': self.contribution_type.id, + 'contribution_date': timezone.now().date().isoformat(), + 'notes': 'Test', + 'recaptcha': 'test-token', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('appeal', response.data['error']) + + def test_unbanned_user_can_submit_again(self): + """After being unbanned, a user can submit again.""" + self.user.is_banned = True + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/submissions/', { + 'contribution_type': self.contribution_type.id, + 'contribution_date': timezone.now().date().isoformat(), + 'notes': 'Test', + 'recaptcha': 'test-token', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Unban the user + self.user.is_banned = False + self.user.save() + + response = self.client.post('/api/v1/submissions/', { + 'contribution_type': self.contribution_type.id, + 'contribution_date': timezone.now().date().isoformat(), + 'notes': 'I am back with quality content', + 'recaptcha': 'test-token', + }, format='json') + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/contributions/tests/test_calibration_data.py b/backend/contributions/tests/test_calibration_data.py new file mode 100644 index 00000000..f6987824 --- /dev/null +++ b/backend/contributions/tests/test_calibration_data.py @@ -0,0 +1,455 @@ +""" +Tests for the calibration data system — structured data in SubmissionNote. + +Verifies that both AI and human review actions store structured data +in the SubmissionNote.data JSONField for later calibration comparison. +""" + +from datetime import timedelta + +from django.test import override_settings +from django.utils import timezone +from rest_framework.test import APITestCase + +from contributions.models import ( + Category, + ContributionType, + Evidence, + SubmissionNote, + SubmittedContribution, +) +from leaderboard.models import GlobalLeaderboardMultiplier +from stewards.models import ReviewTemplate, Steward, StewardPermission +from users.models import User + + +def _create_test_fixtures(): + """Create common test fixtures for calibration tests.""" + category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder', 'description': 'Builder category'}, + ) + ct, _ = ContributionType.objects.get_or_create( + slug='educational-content', + defaults={ + 'name': 'Educational Content', + 'category': category, + 'min_points': 1, + 'max_points': 10, + }, + ) + # Create multiplier for the contribution type + GlobalLeaderboardMultiplier.objects.create( + contribution_type=ct, + multiplier_value=1.0, + valid_from=timezone.now() - timedelta(days=30), + ) + submitter = User.objects.create_user( + email='submitter@test.com', + address='0x1111111111111111111111111111111111111111', + ) + steward_user = User.objects.create_user( + email='steward@test.com', + address='0x2222222222222222222222222222222222222222', + name='Test Steward', + ) + steward_user.is_staff = True + steward_user.save() + steward, _ = Steward.objects.get_or_create(user=steward_user) + for action in ['accept', 'reject', 'request_more_info', 'propose']: + StewardPermission.objects.get_or_create( + steward=steward, contribution_type=ct, action=action, + ) + ai_user, created = User.objects.get_or_create( + email='genlayer-steward@genlayer.foundation', + defaults={ + 'address': '0x3333333333333333333333333333333333333333', + 'name': 'GenLayer Steward', + }, + ) + + submission = SubmittedContribution.objects.create( + user=submitter, + contribution_type=ct, + contribution_date=timezone.now(), + notes='Built a great tutorial about GenLayer smart contracts', + state='pending', + ) + Evidence.objects.create( + submitted_contribution=submission, + url='https://github.com/user/genlayer-tutorial', + ) + + template = ReviewTemplate.objects.create( + label='Accept: Good Content', + text='Thank you for your valuable contribution!', + action='accept', + ) + reject_template = ReviewTemplate.objects.create( + label='Reject: No Evidence', + text='Your submission lacks evidence of the work described.', + action='reject', + ) + + return { + 'category': category, + 'ct': ct, + 'submitter': submitter, + 'steward_user': steward_user, + 'steward': steward, + 'ai_user': ai_user, + 'submission': submission, + 'template': template, + 'reject_template': reject_template, + } + + +@override_settings(ALLOWED_HOSTS=['*']) +class TestHumanReviewNotes(APITestCase): + """Test that human steward review actions store structured data in notes.""" + + def setUp(self): + self.fixtures = _create_test_fixtures() + self.client.force_authenticate(user=self.fixtures['steward_user']) + + def test_human_review_note_captures_data(self): + """When a steward reviews via /review/, the SubmissionNote.data has structured fields.""" + submission = self.fixtures['submission'] + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'reject', + 'staff_reply': 'Not enough evidence provided.', + 'template_id': self.fixtures['reject_template'].id, + }, + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=False, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['action'], 'reject') + self.assertEqual(note.data['staff_reply'], 'Not enough evidence provided.') + self.assertEqual(note.data['template_id'], self.fixtures['reject_template'].id) + self.assertIsNone(note.data['points']) + + def test_human_review_accept_captures_points(self): + """Accept review captures points in structured data.""" + submission = self.fixtures['submission'] + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 5, + 'contribution_type': self.fixtures['ct'].id, + 'staff_reply': 'Great work!', + 'template_id': self.fixtures['template'].id, + }, + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=False, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['action'], 'accept') + self.assertEqual(note.data['points'], 5) + self.assertEqual(note.data['template_id'], self.fixtures['template'].id) + + def test_template_id_optional(self): + """Review works fine without template_id (backwards compatible).""" + submission = self.fixtures['submission'] + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'reject', + 'staff_reply': 'Not enough evidence.', + }, + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=False, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['action'], 'reject') + self.assertIsNone(note.data.get('template_id')) + + def test_human_propose_note_captures_data(self): + """When a steward proposes via /propose/, the note.data has structured fields.""" + submission = self.fixtures['submission'] + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/propose/', + data={ + 'proposed_action': 'reject', + 'proposed_staff_reply': 'This looks like spam.', + 'template_id': self.fixtures['reject_template'].id, + }, + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=True, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['action'], 'reject') + self.assertEqual(note.data['staff_reply'], 'This looks like spam.') + self.assertEqual(note.data['template_id'], self.fixtures['reject_template'].id) + + +@override_settings(ALLOWED_HOSTS=['*']) +class TestAIReviewNotes(APITestCase): + """Test that AI-created notes store structured data.""" + + def setUp(self): + self.fixtures = _create_test_fixtures() + + def test_ai_proposal_note_has_structured_data(self): + """When AI creates a proposal note, data contains structured review info.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + + note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI proposal (medium confidence): reject\nReasoning: Low quality', + is_proposal=True, + data={ + 'action': 'reject', + 'points': None, + 'staff_reply': 'Your submission lacks evidence.', + 'template_id': self.fixtures['reject_template'].id, + 'confidence': 'medium', + 'flags': ['low_effort'], + 'reasoning': 'Low quality submission with no evidence', + }, + ) + + self.assertEqual(note.data['action'], 'reject') + self.assertEqual(note.data['confidence'], 'medium') + self.assertEqual(note.data['template_id'], self.fixtures['reject_template'].id) + self.assertIn('low_effort', note.data['flags']) + self.assertIn('Low quality', note.data['reasoning']) + + def test_ai_auto_action_note_has_structured_data(self): + """When AI auto-rejects (high confidence), the note.data has confidence='high'.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + + note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI auto-reject (HIGH confidence): Spam content', + is_proposal=False, + data={ + 'action': 'reject', + 'points': None, + 'staff_reply': 'Spam submission.', + 'template_id': self.fixtures['reject_template'].id, + 'confidence': 'high', + 'flags': ['ai_generated'], + 'reasoning': 'Clearly spam content', + }, + ) + + self.assertEqual(note.data['confidence'], 'high') + self.assertFalse(note.is_proposal) + + def test_tier1_reject_note_has_structured_data(self): + """Tier 1 auto-reject notes have data with action='reject' and the rule reason.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + + crm_reason = 'Tier 1 auto-reject: No evidence and no notes provided.' + note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message=crm_reason, + is_proposal=False, + data={ + 'action': 'reject', + 'points': None, + 'staff_reply': 'Your submission lacks evidence.', + 'template_id': self.fixtures['reject_template'].id, + 'confidence': 'high', + 'flags': [], + 'reasoning': crm_reason, + }, + ) + + self.assertEqual(note.data['action'], 'reject') + self.assertEqual(note.data['confidence'], 'high') + self.assertIn('Tier 1', note.data['reasoning']) + + +@override_settings(ALLOWED_HOSTS=['*']) +class TestCalibrationComparison(APITestCase): + """Test that AI vs human notes can be compared for calibration.""" + + def setUp(self): + self.fixtures = _create_test_fixtures() + + def test_can_compare_ai_vs_human_notes(self): + """For a submission with both AI and human notes, data fields can be compared.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + steward_user = self.fixtures['steward_user'] + + # AI proposes reject + ai_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI proposal: reject', + is_proposal=True, + data={ + 'action': 'reject', + 'points': None, + 'staff_reply': 'Low quality.', + 'template_id': self.fixtures['reject_template'].id, + 'confidence': 'medium', + 'flags': ['low_effort'], + 'reasoning': 'Low effort submission', + }, + ) + + # Human accepts instead + human_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=steward_user, + message='Reviewed: accept with 3 points', + is_proposal=False, + data={ + 'action': 'accept', + 'points': 3, + 'staff_reply': 'Decent tutorial, thanks!', + 'template_id': self.fixtures['template'].id, + }, + ) + + # Compare the two notes + self.assertNotEqual(ai_note.data['action'], human_note.data['action']) + self.assertEqual(ai_note.data['action'], 'reject') + self.assertEqual(human_note.data['action'], 'accept') + self.assertEqual(human_note.data['points'], 3) + + def test_disagreement_detection(self): + """AI proposed accept, human rejected -> data fields show different actions.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + steward_user = self.fixtures['steward_user'] + + ai_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI proposal: accept', + is_proposal=True, + data={'action': 'accept', 'points': 5, 'confidence': 'medium'}, + ) + + human_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=steward_user, + message='Reviewed: reject', + is_proposal=False, + data={'action': 'reject', 'points': None}, + ) + + # Detect disagreement + is_disagreement = ai_note.data['action'] != human_note.data['action'] + self.assertTrue(is_disagreement) + + def test_agreement_detection(self): + """AI proposed reject, human also rejected -> data fields show same action.""" + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + steward_user = self.fixtures['steward_user'] + + ai_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI proposal: reject', + is_proposal=True, + data={'action': 'reject', 'points': None, 'confidence': 'high'}, + ) + + human_note = SubmissionNote.objects.create( + submitted_contribution=submission, + user=steward_user, + message='Reviewed: reject', + is_proposal=False, + data={'action': 'reject', 'points': None}, + ) + + # Detect agreement + is_agreement = ai_note.data['action'] == human_note.data['action'] + self.assertTrue(is_agreement) + + def test_calibration_query_pattern(self): + """ + Verify the calibration query pattern works: find submissions + reviewed by humans that also have AI notes, and compare. + """ + submission = self.fixtures['submission'] + ai_user = self.fixtures['ai_user'] + steward_user = self.fixtures['steward_user'] + + # Mark submission as reviewed by human + submission.state = 'rejected' + submission.reviewed_by = steward_user + submission.reviewed_at = timezone.now() + submission.save() + + # Create AI note and human note + SubmissionNote.objects.create( + submitted_contribution=submission, + user=ai_user, + message='AI proposal: accept', + is_proposal=True, + data={'action': 'accept', 'points': 3, 'confidence': 'medium'}, + ) + SubmissionNote.objects.create( + submitted_contribution=submission, + user=steward_user, + message='Reviewed: reject', + is_proposal=False, + data={'action': 'reject', 'points': None}, + ) + + # Query pattern: find human-reviewed submissions with AI notes + reviewed = SubmittedContribution.objects.filter( + reviewed_at__isnull=False, + ).exclude( + reviewed_by=ai_user, + ).prefetch_related('internal_notes') + + calibration_pairs = [] + for sub in reviewed: + ai_note = sub.internal_notes.filter(user=ai_user).order_by('-created_at').first() + human_note = ( + sub.internal_notes + .filter(is_proposal=False) + .exclude(user=ai_user) + .order_by('-created_at') + .first() + ) + if ai_note and human_note and ai_note.data and human_note.data: + calibration_pairs.append({ + 'submission_id': str(sub.id), + 'ai_action': ai_note.data.get('action'), + 'human_action': human_note.data.get('action'), + 'ai_confidence': ai_note.data.get('confidence'), + }) + + self.assertEqual(len(calibration_pairs), 1) + self.assertEqual(calibration_pairs[0]['ai_action'], 'accept') + self.assertEqual(calibration_pairs[0]['human_action'], 'reject') + self.assertEqual(calibration_pairs[0]['ai_confidence'], 'medium') diff --git a/backend/contributions/tests/test_review_submissions.py b/backend/contributions/tests/test_review_submissions.py new file mode 100644 index 00000000..86e005db --- /dev/null +++ b/backend/contributions/tests/test_review_submissions.py @@ -0,0 +1,578 @@ +from datetime import timedelta +from io import StringIO +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.utils import timezone + +from contributions.models import ( + Category, ContributionType, Evidence, + SubmittedContribution, SubmissionNote, +) +from contributions.management.commands.review_submissions import ( + TIER1_RULES, + rule_no_evidence_no_notes, + rule_no_evidence_short_notes, + rule_spam_notes, + rule_duplicate_pending_from_same_user, + rule_resubmitted_rejected_url, + rule_same_url_reused_by_same_user, + rule_blocklisted_evidence_url, + rule_cross_user_duplicate_url, + rule_cross_user_identical_notes, +) +from stewards.models import ReviewTemplate + +User = get_user_model() + + +class Tier1RuleTestBase(TestCase): + """Base class with shared setup for Tier 1 rule tests.""" + + def setUp(self): + self.category = Category.objects.create( + name='Test', slug='test', description='Test', + ) + self.ctype = ContributionType.objects.create( + name='Test Type', slug='test-type', + description='Test', category=self.category, + min_points=1, max_points=100, + ) + self.user = User.objects.create_user( + email='user@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.other_user = User.objects.create_user( + email='other@test.com', + address='0x0987654321098765432109876543210987654321', + password='testpass123', + ) + + def _create_submission(self, user=None, notes='', state='pending', + created_at=None): + sub = SubmittedContribution.objects.create( + user=user or self.user, + contribution_type=self.ctype, + contribution_date=timezone.now(), + notes=notes, + state=state, + ) + if created_at: + SubmittedContribution.objects.filter(id=sub.id).update( + created_at=created_at, + ) + sub.refresh_from_db() + return sub + + def _add_evidence(self, submission, url='', description=''): + return Evidence.objects.create( + submitted_contribution=submission, + url=url, + description=description, + ) + + +class RuleNoEvidenceNoNotesTest(Tier1RuleTestBase): + """Test rule_no_evidence_no_notes.""" + + def test_catches_empty_submission(self): + sub = self._create_submission(notes='') + result = rule_no_evidence_no_notes(sub, []) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: No Evidence') + + def test_passes_with_notes(self): + sub = self._create_submission(notes='I built something') + result = rule_no_evidence_no_notes(sub, []) + self.assertIsNone(result) + + def test_passes_with_evidence(self): + sub = self._create_submission(notes='') + ev = self._add_evidence(sub, url='https://github.com/test/repo') + result = rule_no_evidence_no_notes(sub, [ev]) + self.assertIsNone(result) + + +class RuleNoEvidenceShortNotesTest(Tier1RuleTestBase): + """Test rule_no_evidence_short_notes.""" + + def test_catches_short_notes_no_evidence(self): + sub = self._create_submission(notes='ok') + result = rule_no_evidence_short_notes(sub, []) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: No Evidence') + + def test_passes_longer_notes(self): + sub = self._create_submission(notes='This is a longer note') + result = rule_no_evidence_short_notes(sub, []) + self.assertIsNone(result) + + def test_passes_with_evidence(self): + sub = self._create_submission(notes='ok') + ev = self._add_evidence(sub, url='https://example.com') + result = rule_no_evidence_short_notes(sub, [ev]) + self.assertIsNone(result) + + def test_boundary_10_chars(self): + sub = self._create_submission(notes='1234567890') + result = rule_no_evidence_short_notes(sub, []) + self.assertIsNotNone(result) + + def test_11_chars_passes(self): + sub = self._create_submission(notes='12345678901') + result = rule_no_evidence_short_notes(sub, []) + self.assertIsNone(result) + + +class RuleSpamNotesTest(Tier1RuleTestBase): + """Test rule_spam_notes.""" + + def test_catches_spam_words(self): + for word in ['good', 'hello', 'test', 'airdrop', 'nothing']: + sub = self._create_submission(notes=word) + result = rule_spam_notes(sub, []) + self.assertIsNotNone(result, f'Should catch spam word: {word}') + self.assertEqual(result[0], 'Reject: Unintelligible Notes') + + def test_catches_gibberish(self): + sub = self._create_submission(notes='12345') + result = rule_spam_notes(sub, []) + self.assertIsNotNone(result) + + def test_catches_symbols(self): + sub = self._create_submission(notes='!!!') + result = rule_spam_notes(sub, []) + self.assertIsNotNone(result) + + def test_passes_real_notes(self): + sub = self._create_submission( + notes='I built a smart contract for GenLayer', + ) + result = rule_spam_notes(sub, []) + self.assertIsNone(result) + + def test_case_insensitive(self): + sub = self._create_submission(notes='GOOD') + result = rule_spam_notes(sub, []) + self.assertIsNotNone(result) + + +class RuleDuplicatePendingTest(Tier1RuleTestBase): + """Test rule_duplicate_pending_from_same_user.""" + + def test_catches_duplicate_notes_same_user(self): + older = self._create_submission( + notes='My project', created_at=timezone.now() - timedelta(days=2), + ) + newer = self._create_submission( + notes='My project', created_at=timezone.now(), + ) + result = rule_duplicate_pending_from_same_user(newer, []) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: Duplicate Submission') + + def test_keeps_older_submission(self): + older = self._create_submission( + notes='My project', created_at=timezone.now() - timedelta(days=2), + ) + newer = self._create_submission( + notes='My project', created_at=timezone.now(), + ) + result = rule_duplicate_pending_from_same_user(older, []) + self.assertIsNone(result) + + def test_different_notes_pass(self): + self._create_submission( + notes='Project A', created_at=timezone.now() - timedelta(days=2), + ) + newer = self._create_submission( + notes='Project B', created_at=timezone.now(), + ) + result = rule_duplicate_pending_from_same_user(newer, []) + self.assertIsNone(result) + + def test_different_user_passes(self): + self._create_submission( + user=self.other_user, notes='Same notes', + created_at=timezone.now() - timedelta(days=2), + ) + newer = self._create_submission( + notes='Same notes', created_at=timezone.now(), + ) + result = rule_duplicate_pending_from_same_user(newer, []) + self.assertIsNone(result) + + def test_empty_notes_skipped(self): + self._create_submission( + notes='', created_at=timezone.now() - timedelta(days=2), + ) + newer = self._create_submission( + notes='', created_at=timezone.now(), + ) + result = rule_duplicate_pending_from_same_user(newer, []) + self.assertIsNone(result) + + +class RuleResubmittedRejectedUrlTest(Tier1RuleTestBase): + """Test rule_resubmitted_rejected_url.""" + + def test_catches_previously_rejected_url(self): + rejected_sub = self._create_submission( + notes='Old submission', state='rejected', + ) + self._add_evidence(rejected_sub, url='https://example.com/my-post') + + new_sub = self._create_submission(notes='Resubmitting') + ev = self._add_evidence(new_sub, url='https://example.com/my-post') + + result = rule_resubmitted_rejected_url(new_sub, [ev]) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: Duplicate Submission') + + def test_passes_new_url(self): + rejected_sub = self._create_submission( + notes='Old submission', state='rejected', + ) + self._add_evidence(rejected_sub, url='https://example.com/old-post') + + new_sub = self._create_submission(notes='New content') + ev = self._add_evidence(new_sub, url='https://example.com/new-post') + + result = rule_resubmitted_rejected_url(new_sub, [ev]) + self.assertIsNone(result) + + def test_different_user_passes(self): + """URL rejected for another user is not caught by this rule.""" + rejected_sub = self._create_submission( + user=self.other_user, notes='Their submission', state='rejected', + ) + self._add_evidence(rejected_sub, url='https://example.com/post') + + new_sub = self._create_submission(notes='My submission') + ev = self._add_evidence(new_sub, url='https://example.com/post') + + result = rule_resubmitted_rejected_url(new_sub, [ev]) + self.assertIsNone(result) + + def test_no_evidence_passes(self): + sub = self._create_submission(notes='No evidence') + result = rule_resubmitted_rejected_url(sub, []) + self.assertIsNone(result) + + +class RuleSameUrlReusedTest(Tier1RuleTestBase): + """Test rule_same_url_reused_by_same_user.""" + + def test_catches_url_reuse(self): + older = self._create_submission( + notes='First use', created_at=timezone.now() - timedelta(days=1), + ) + self._add_evidence(older, url='https://example.com/my-repo') + + newer = self._create_submission( + notes='Second use', created_at=timezone.now(), + ) + ev = self._add_evidence(newer, url='https://example.com/my-repo') + + result = rule_same_url_reused_by_same_user(newer, [ev]) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: Duplicate Submission') + + def test_older_submission_passes(self): + older = self._create_submission( + notes='First use', created_at=timezone.now() - timedelta(days=1), + ) + ev_older = self._add_evidence( + older, url='https://example.com/my-repo', + ) + + self._create_submission( + notes='Second use', created_at=timezone.now(), + ) + + result = rule_same_url_reused_by_same_user(older, [ev_older]) + self.assertIsNone(result) + + +class RuleBlocklistedUrlTest(Tier1RuleTestBase): + """Test rule_blocklisted_evidence_url.""" + + def test_catches_studio_url(self): + sub = self._create_submission(notes='My work') + ev = self._add_evidence( + sub, url='https://studio.genlayer.com/run-debug', + ) + result = rule_blocklisted_evidence_url(sub, [ev]) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: No Evidence') + + def test_catches_points_url(self): + sub = self._create_submission(notes='My work') + ev = self._add_evidence( + sub, url='https://points.genlayer.foundation/#/submit', + ) + result = rule_blocklisted_evidence_url(sub, [ev]) + self.assertIsNotNone(result) + + def test_catches_studio_url_with_fragment(self): + sub = self._create_submission(notes='My work') + ev = self._add_evidence( + sub, url='https://studio.genlayer.com/run-debug#something', + ) + result = rule_blocklisted_evidence_url(sub, [ev]) + self.assertIsNotNone(result) + + def test_passes_real_evidence(self): + sub = self._create_submission(notes='My work') + ev = self._add_evidence( + sub, url='https://github.com/user/genlayer-project', + ) + result = rule_blocklisted_evidence_url(sub, [ev]) + self.assertIsNone(result) + + def test_passes_if_mixed_evidence(self): + """If one URL is blocklisted but another is real, don't reject.""" + sub = self._create_submission(notes='My work') + ev1 = self._add_evidence( + sub, url='https://studio.genlayer.com/run-debug', + ) + ev2 = self._add_evidence( + sub, url='https://github.com/user/real-project', + ) + result = rule_blocklisted_evidence_url(sub, [ev1, ev2]) + self.assertIsNone(result) + + +class RuleCrossUserDuplicateUrlTest(Tier1RuleTestBase): + """Test rule_cross_user_duplicate_url.""" + + def test_catches_cross_user_tweet(self): + """Same tweet URL submitted by different users.""" + other_sub = self._create_submission( + user=self.other_user, notes='My tweet', + ) + self._add_evidence( + other_sub, url='https://x.com/i/status/123456789', + ) + + my_sub = self._create_submission(notes='Also my tweet') + ev = self._add_evidence( + my_sub, url='https://x.com/i/status/123456789', + ) + + result = rule_cross_user_duplicate_url(my_sub, [ev]) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: Duplicate Submission') + + def test_github_repos_excluded(self): + """GitHub repo URLs are excluded (can be forked legitimately).""" + other_sub = self._create_submission( + user=self.other_user, notes='My repo', + ) + self._add_evidence( + other_sub, url='https://github.com/other/genlayer-tool', + ) + + my_sub = self._create_submission(notes='My fork') + ev = self._add_evidence( + my_sub, url='https://github.com/other/genlayer-tool', + ) + + result = rule_cross_user_duplicate_url(my_sub, [ev]) + self.assertIsNone(result) + + def test_same_user_passes(self): + """Same user reusing a URL is handled by another rule.""" + older = self._create_submission( + notes='First', created_at=timezone.now() - timedelta(days=1), + ) + self._add_evidence( + older, url='https://x.com/i/status/123456789', + ) + + newer = self._create_submission(notes='Second') + ev = self._add_evidence( + newer, url='https://x.com/i/status/123456789', + ) + + result = rule_cross_user_duplicate_url(newer, [ev]) + self.assertIsNone(result) + + +class RuleCrossUserIdenticalNotesTest(Tier1RuleTestBase): + """Test rule_cross_user_identical_notes.""" + + def test_catches_farming_template(self): + """Same long notes from different users.""" + long_notes = 'I shared GenLayer information on Telegram group for airdrop farming and community building.' + self._create_submission(user=self.other_user, notes=long_notes) + my_sub = self._create_submission(notes=long_notes) + + result = rule_cross_user_identical_notes(my_sub, []) + self.assertIsNotNone(result) + self.assertEqual(result[0], 'Reject: Duplicate Submission') + + def test_short_notes_ignored(self): + """Notes <= 20 chars are not checked.""" + self._create_submission(user=self.other_user, notes='short note here') + my_sub = self._create_submission(notes='short note here') + + result = rule_cross_user_identical_notes(my_sub, []) + self.assertIsNone(result) + + def test_system_generated_ignored(self): + """System-generated notes starting with 'Automatic submission' are skipped.""" + auto_notes = 'Automatic submission for node upgrade to version 1.2.3' + self._create_submission(user=self.other_user, notes=auto_notes) + my_sub = self._create_submission(notes=auto_notes) + + result = rule_cross_user_identical_notes(my_sub, []) + self.assertIsNone(result) + + def test_same_user_passes(self): + """Same user with identical notes handled by duplicate rule.""" + notes = 'A detailed description of my contribution to GenLayer ecosystem' + self._create_submission( + notes=notes, created_at=timezone.now() - timedelta(days=1), + ) + newer = self._create_submission(notes=notes) + + result = rule_cross_user_identical_notes(newer, []) + self.assertIsNone(result) + + +class AutoBanTest(TestCase): + """Test the auto-ban functionality in the review_submissions command.""" + + def setUp(self): + self.category = Category.objects.create( + name='Test', slug='test', description='Test', + ) + self.ctype = ContributionType.objects.create( + name='Test Type', slug='test-type', + description='Test', category=self.category, + min_points=1, max_points=100, + ) + # Create a user with many rejections + self.spammer = User.objects.create_user( + email='spammer@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + for i in range(6): + SubmittedContribution.objects.create( + user=self.spammer, + contribution_type=self.ctype, + contribution_date=timezone.now(), + notes=f'Spam submission {i}', + state='rejected', + ) + + # Create a legitimate user with some rejections but also acceptances + self.legit_user = User.objects.create_user( + email='legit@test.com', + address='0x0987654321098765432109876543210987654321', + password='testpass123', + ) + for i in range(3): + SubmittedContribution.objects.create( + user=self.legit_user, + contribution_type=self.ctype, + contribution_date=timezone.now(), + notes=f'Rejected {i}', + state='rejected', + ) + SubmittedContribution.objects.create( + user=self.legit_user, + contribution_type=self.ctype, + contribution_date=timezone.now(), + notes='Accepted one', + state='accepted', + ) + + # Create a user with exactly 4 rejections (below threshold) + self.borderline_user = User.objects.create_user( + email='borderline@test.com', + address='0x1111111111111111111111111111111111111111', + password='testpass123', + ) + for i in range(4): + SubmittedContribution.objects.create( + user=self.borderline_user, + contribution_type=self.ctype, + contribution_date=timezone.now(), + notes=f'Rejected {i}', + state='rejected', + ) + + # Create a review template so the command doesn't exit early + ReviewTemplate.objects.create( + label='Reject: No Evidence', + text='Your submission lacks evidence.', + ) + + @patch('contributions.management.commands.review_submissions.call_openrouter') + def test_auto_ban_spammer(self, mock_openrouter): + """User with 5+ rejections and 0 acceptances gets auto-banned.""" + out = StringIO() + call_command( + 'review_submissions', '--tier1-only', '--batch-size', '0', + stdout=out, + ) + self.spammer.refresh_from_db() + self.assertTrue(self.spammer.is_banned) + self.assertIn('6', self.spammer.ban_reason) # mentions rejection count + self.assertIsNotNone(self.spammer.banned_at) + + @patch('contributions.management.commands.review_submissions.call_openrouter') + def test_no_ban_legit_user(self, mock_openrouter): + """User with acceptances is not auto-banned.""" + out = StringIO() + call_command( + 'review_submissions', '--tier1-only', '--batch-size', '0', + stdout=out, + ) + self.legit_user.refresh_from_db() + self.assertFalse(self.legit_user.is_banned) + + @patch('contributions.management.commands.review_submissions.call_openrouter') + def test_no_ban_below_threshold(self, mock_openrouter): + """User with <5 rejections is not auto-banned.""" + out = StringIO() + call_command( + 'review_submissions', '--tier1-only', '--batch-size', '0', + stdout=out, + ) + self.borderline_user.refresh_from_db() + self.assertFalse(self.borderline_user.is_banned) + + @patch('contributions.management.commands.review_submissions.call_openrouter') + def test_dry_run_no_ban(self, mock_openrouter): + """Dry run does not actually ban users.""" + out = StringIO() + call_command( + 'review_submissions', '--tier1-only', '--dry-run', + '--batch-size', '0', stdout=out, + ) + self.spammer.refresh_from_db() + self.assertFalse(self.spammer.is_banned) + self.assertIn('auto-ban', out.getvalue().lower()) + + @patch('contributions.management.commands.review_submissions.call_openrouter') + def test_already_banned_not_rebanned(self, mock_openrouter): + """Already-banned user is not processed again.""" + self.spammer.is_banned = True + self.spammer.ban_reason = 'Already banned' + self.spammer.save() + + out = StringIO() + call_command( + 'review_submissions', '--tier1-only', '--batch-size', '0', + stdout=out, + ) + self.spammer.refresh_from_db() + # ban_reason should not be overwritten + self.assertEqual(self.spammer.ban_reason, 'Already banned') diff --git a/backend/contributions/views.py b/backend/contributions/views.py index fc10bc19..34114a87 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -625,6 +625,15 @@ def get_serializer_context(self): def create(self, request, *args, **kwargs): """Create a new submission with optional mission tracking.""" + # Block banned users from submitting + if request.user.is_banned: + return Response( + {'error': 'Your account has been suspended due to repeated ' + 'policy violations. You may submit one appeal from ' + 'your profile page.'}, + status=status.HTTP_403_FORBIDDEN, + ) + mission_id = request.data.get('mission') data = request.data.copy() # Create mutable copy for modifications @@ -793,6 +802,24 @@ class StewardSubmissionFilterSet(FilterSet): exclude_state = CharFilter(method='filter_exclude_state') min_accepted_contributions = NumberFilter(method='filter_min_accepted_contributions') has_proposal = BooleanFilter(method='filter_has_proposal') + search = CharFilter(method='filter_search') + + def filter_search(self, queryset, name, value): + """General search across user name/email/address, notes, and evidence URLs.""" + if value: + has_matching_evidence = Evidence.objects.filter( + submitted_contribution=OuterRef('pk') + ).filter( + Q(url__icontains=value) | Q(description__icontains=value) + ) + return queryset.filter( + Q(user__name__icontains=value) | + Q(user__email__icontains=value) | + Q(user__address__icontains=value) | + Q(notes__icontains=value) | + Exists(has_matching_evidence) + ) + return queryset def filter_username(self, queryset, name, value): """Filter by submitter name, email, or address (case-insensitive partial match).""" @@ -1108,13 +1135,19 @@ def review(self, request, pk=None): user=request.user, message=f"Reviewed: **{action_name}**{pts_str} by {reviewer_name}", is_proposal=False, + data={ + 'action': action_name, + 'points': serializer.validated_data.get('points'), + 'staff_reply': serializer.validated_data.get('staff_reply', ''), + 'template_id': serializer.validated_data.get('template_id'), + }, ) return Response( self.get_serializer(submission).data, status=status.HTTP_200_OK ) - + @action(detail=False, methods=['get'], url_path='stats') def stats(self, request): """Get statistics for steward dashboard.""" @@ -1499,10 +1532,13 @@ def my_permissions(self, request): @action(detail=False, methods=['get'], url_path='templates') def templates(self, request): - """Get all review templates.""" + """Get all review templates, optionally filtered by action.""" from stewards.models import ReviewTemplate templates = ReviewTemplate.objects.all() - data = [{'id': t.id, 'label': t.label, 'text': t.text} for t in templates] + action_filter = request.query_params.get('action') + if action_filter in ('accept', 'reject', 'more_info'): + templates = templates.filter(action=action_filter) + data = [{'id': t.id, 'label': t.label, 'text': t.text, 'action': t.action} for t in templates] return Response(data) @action(detail=True, methods=['post'], url_path='propose') @@ -1550,6 +1586,12 @@ def propose(self, request, pk=None): user=request.user, message=f"Proposed: **{action_str}**{pts_str}{ct_str}{user_str}{reply_str} by {proposer_name}", is_proposal=True, + data={ + 'action': data['proposed_action'], + 'points': data.get('proposed_points'), + 'staff_reply': data.get('proposed_staff_reply', ''), + 'template_id': data.get('template_id'), + }, ) return Response( @@ -1587,6 +1629,63 @@ def notes(self, request, pk=None): status=status.HTTP_201_CREATED ) + @action(detail=False, methods=['get'], url_path='ban-appeals') + def ban_appeals(self, request): + """List ban appeals, optionally filtered by status.""" + from users.models import BanAppeal + from users.serializers import BanAppealSerializer + + status_filter = request.query_params.get('status') + qs = BanAppeal.objects.select_related( + 'user', 'reviewed_by', + ).order_by('-created_at') + if status_filter: + qs = qs.filter(status=status_filter) + + serializer = BanAppealSerializer(qs, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['post'], + url_path='ban-appeals/(?P[^/.]+)/review') + def review_ban_appeal(self, request, appeal_id=None): + """Approve or deny a ban appeal.""" + from users.models import BanAppeal + from users.serializers import BanAppealReviewSerializer, BanAppealSerializer + + appeal = get_object_or_404(BanAppeal, id=appeal_id) + + if appeal.status != 'pending': + return Response( + {'error': f'Appeal has already been {appeal.status}.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = BanAppealReviewSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + action_taken = serializer.validated_data['action'] + review_notes = serializer.validated_data.get('review_notes', '') + + appeal.reviewed_by = request.user + appeal.reviewed_at = timezone.now() + appeal.review_notes = review_notes + + if action_taken == 'approve': + appeal.status = 'approved' + appeal.save() + # Unban the user + user = appeal.user + user.is_banned = False + user.ban_reason = '' + user.banned_at = None + user.banned_by = None + user.save() + else: + appeal.status = 'denied' + appeal.save() + + return Response(BanAppealSerializer(appeal).data) + class MissionViewSet(viewsets.ReadOnlyModelViewSet): """ diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index faa4b87a..a12f5b83 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -383,12 +383,16 @@ def update_leaderboard_on_contribution(sender, instance, created, **kwargs): When a contribution is saved, update all affected leaderboard entries. Also updates referrer's referral points if the user was referred. """ + # Skip during fixture loading (loaddata) to avoid ordering issues + if kwargs.get('raw', False): + return + # Only update if points have changed or it's a new contribution if created or kwargs.get('update_fields') is None or 'points' in kwargs.get('update_fields', []): # Log the contribution's point calculation logger.debug(f"Contribution saved: {instance.points} points × {instance.multiplier_at_creation} = " f"{instance.frozen_global_points} global points") - + # Update the user's leaderboard entries update_user_leaderboard_entries(instance.user) @@ -404,6 +408,9 @@ def update_leaderboard_on_builder_creation(sender, instance, created, **kwargs): their Builder profile is created, even if the profile was created after their contributions (which is the case in complete_builder_journey). """ + # Skip during fixture loading (loaddata) to avoid ordering issues + if kwargs.get('raw', False): + return if created: from users.models import User # Re-fetch user from DB to avoid stale reverse-relation cache diff --git a/backend/restore_db.sh b/backend/restore_db.sh new file mode 100755 index 00000000..2c83d298 --- /dev/null +++ b/backend/restore_db.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cp /workspace/backend/db.sqlite3.backup_production_20260225 /workspace/backend/db.sqlite3 +echo "Database restored from production backup" diff --git a/backend/stewards/admin.py b/backend/stewards/admin.py index 09e96865..8b2f3949 100644 --- a/backend/stewards/admin.py +++ b/backend/stewards/admin.py @@ -56,10 +56,11 @@ class StewardPermissionAdmin(admin.ModelAdmin): @admin.register(ReviewTemplate) class ReviewTemplateAdmin(admin.ModelAdmin): - list_display = ('label', 'text_preview', 'created_at', 'updated_at') + list_display = ('action', 'label', 'text_preview', 'created_at', 'updated_at') + list_filter = ('action',) search_fields = ('label', 'text') readonly_fields = ('created_at', 'updated_at') - ordering = ('label',) + ordering = ('action', 'label') def text_preview(self, obj): return obj.text[:80] + '...' if len(obj.text) > 80 else obj.text diff --git a/backend/stewards/migrations/0006_create_ai_steward.py b/backend/stewards/migrations/0006_create_ai_steward.py new file mode 100644 index 00000000..7926063c --- /dev/null +++ b/backend/stewards/migrations/0006_create_ai_steward.py @@ -0,0 +1,69 @@ +from django.db import migrations + + +AI_STEWARD_EMAIL = 'genlayer-steward@genlayer.foundation' +AI_STEWARD_NAME = 'GenLayer Steward' + +ACTIONS = ['propose', 'accept', 'reject', 'request_more_info'] + + +def create_ai_steward(apps, schema_editor): + """ + Create the AI steward user with full permissions on all contribution types. + Used by the review_submissions management command for automated review. + """ + User = apps.get_model('users', 'User') + Steward = apps.get_model('stewards', 'Steward') + StewardPermission = apps.get_model('stewards', 'StewardPermission') + ContributionType = apps.get_model('contributions', 'ContributionType') + + # Create user (visible=False so it doesn't appear on leaderboard/public) + user, created = User.objects.get_or_create( + email=AI_STEWARD_EMAIL, + defaults={ + 'name': AI_STEWARD_NAME, + 'visible': False, + 'password': '!', # Unusable password marker + }, + ) + + # Create steward profile + steward, _ = Steward.objects.get_or_create(user=user) + + # Grant all permissions on all contribution types + contribution_types = ContributionType.objects.all() + permissions_to_create = [] + for ct in contribution_types: + for action in ACTIONS: + permissions_to_create.append( + StewardPermission( + steward=steward, + contribution_type=ct, + action=action, + ) + ) + + if permissions_to_create: + StewardPermission.objects.bulk_create( + permissions_to_create, + ignore_conflicts=True, + ) + + +def remove_ai_steward(apps, schema_editor): + """Remove the AI steward user and all related data.""" + User = apps.get_model('users', 'User') + User.objects.filter(email=AI_STEWARD_EMAIL).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stewards', '0005_grant_all_permissions_to_existing_stewards'), + ('contributions', '0032_submittedcontribution_proposal_fields_submissionnote'), + ('users', '0016_add_github_fields'), + ] + + operations = [ + migrations.RunPython(create_ai_steward, remove_ai_steward), + ] diff --git a/backend/stewards/migrations/0007_add_action_to_reviewtemplate.py b/backend/stewards/migrations/0007_add_action_to_reviewtemplate.py new file mode 100644 index 00000000..ad556dbd --- /dev/null +++ b/backend/stewards/migrations/0007_add_action_to_reviewtemplate.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.2 on 2026-02-26 15:04 + +from django.db import migrations, models + + +def populate_action_from_label(apps, schema_editor): + """Populate action field from label prefix and strip the prefix.""" + ReviewTemplate = apps.get_model('stewards', 'ReviewTemplate') + for t in ReviewTemplate.objects.all(): + if t.label.startswith('Accept: '): + t.action = 'accept' + t.label = t.label[len('Accept: '):] + elif t.label.startswith('More Info: '): + t.action = 'more_info' + t.label = t.label[len('More Info: '):] + elif t.label.startswith('Reject: '): + t.action = 'reject' + t.label = t.label[len('Reject: '):] + else: + t.action = 'reject' # default + t.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stewards', '0006_create_ai_steward'), + ] + + operations = [ + migrations.AlterModelOptions( + name='reviewtemplate', + options={'ordering': ['action', 'label']}, + ), + migrations.AddField( + model_name='reviewtemplate', + name='action', + field=models.CharField(choices=[('accept', 'Accept'), ('reject', 'Reject'), ('more_info', 'More Info')], default='reject', help_text='Which review action this template is for', max_length=20), + ), + migrations.RunPython(populate_action_from_label, migrations.RunPython.noop), + ] diff --git a/backend/stewards/migrations/0008_add_wrong_category_template.py b/backend/stewards/migrations/0008_add_wrong_category_template.py new file mode 100644 index 00000000..3bb17cc8 --- /dev/null +++ b/backend/stewards/migrations/0008_add_wrong_category_template.py @@ -0,0 +1,40 @@ +""" +Add a 'reject' template for non-technical community content submitted +under Builder categories. Directs users to Discord for now. +""" + +from django.db import migrations + + +def create_template(apps, schema_editor): + ReviewTemplate = apps.get_model('stewards', 'ReviewTemplate') + ReviewTemplate.objects.create( + label='Not for Builders: Community Content', + text=( + "Thanks for sharing this! However, this type of content \u2014 social media " + "posts, general awareness tweets, and non-technical overviews \u2014 doesn't " + "qualify for the Builder category, which requires in-depth technical work " + "like tutorials with code, detailed guides, working projects, or original " + "research. To earn XP for community content like this, please submit it " + "through our Discord community instead. A dedicated Community section on " + "the platform is coming soon! In the meantime, head over to Discord to " + "submit your contribution and get rewarded for it." + ), + action='reject', + ) + + +def remove_template(apps, schema_editor): + ReviewTemplate = apps.get_model('stewards', 'ReviewTemplate') + ReviewTemplate.objects.filter(label='Not for Builders: Community Content').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stewards', '0007_add_action_to_reviewtemplate'), + ] + + operations = [ + migrations.RunPython(create_template, remove_template), + ] diff --git a/backend/stewards/models.py b/backend/stewards/models.py index 638f0849..63b7aa60 100644 --- a/backend/stewards/models.py +++ b/backend/stewards/models.py @@ -53,14 +53,23 @@ class ReviewTemplate(BaseModel): """ Admin-managed template messages for steward review workflows. """ + ACTION_CHOICES = [ + ('accept', 'Accept'), + ('reject', 'Reject'), + ('more_info', 'More Info'), + ] label = models.CharField(max_length=100, help_text="Short label, e.g. 'Insufficient evidence'") text = models.TextField(help_text="Full template text to insert into reply fields") + action = models.CharField( + max_length=20, choices=ACTION_CHOICES, default='reject', + help_text="Which review action this template is for", + ) class Meta: - ordering = ['label'] + ordering = ['action', 'label'] def __str__(self): - return self.label + return f"{self.get_action_display()}: {self.label}" class WorkingGroup(BaseModel): diff --git a/backend/submissions_review.md b/backend/submissions_review.md new file mode 100644 index 00000000..2fb374a4 --- /dev/null +++ b/backend/submissions_review.md @@ -0,0 +1,430 @@ +# Submissions Review System + +## Overview + +AI-assisted review pipeline for GenLayer Testnet Program submissions. Processes 1,000+ pending community contributions using deterministic rules and AI classification via Claude (OpenRouter). + +## Architecture + +### Three Tiers + +| Tier | Method | Auto-applies? | Human review? | +|------|--------|--------------|---------------| +| **Tier 1** | Deterministic rules | Yes (reject only) | No | +| **Tier 2 (HIGH confidence)** | AI via Claude | Yes (reject/more_info only) | No | +| **Tier 2 (MEDIUM/LOW)** | AI via Claude | No — creates proposal | Yes | +| **Tier 2 (accept)** | AI via Claude | Never — always proposal | Yes | +| **Auto-ban** | Post-batch check | Yes (ban user) | Appeal via in-app flow | + +### AI Steward + +- **User**: `genlayer-steward@genlayer.foundation` (migration: `stewards/migrations/0006_create_ai_steward.py`) +- **Permissions**: Full steward permissions on all contribution types +- **Visibility**: `visible=False` (hidden from leaderboard/public) + +### Review Templates + +31 templates in `stewards_reviewtemplate` covering rejection, more-info, and acceptance scenarios. Templates will be added to a data migration once finalized. + +## Tier 1: Deterministic Rules + +Rules that auto-reject with 100% confidence. Evaluated in order; first match wins. + +| # | Rule | What it catches | Impact | +|---|------|-----------------|--------| +| 1 | `rule_no_evidence_no_notes` | Zero evidence + blank notes | 9 | +| 2 | `rule_no_evidence_short_notes` | Zero evidence + notes <=10 chars | 38 | +| 3 | `rule_spam_notes` | Notes match spam wordlist or non-alpha gibberish | ~5 | +| 4 | `rule_duplicate_pending_from_same_user` | Older pending submission with identical notes from same user | 27 | +| 5 | `rule_resubmitted_rejected_url` | Same user resubmits a previously-rejected URL | 25 | +| 6 | `rule_same_url_reused_by_same_user` | Same URL in another pending submission by same user | 24 | +| 7 | `rule_blocklisted_evidence_url` | Generic platform URLs as sole evidence (studio, points site) | 11 | +| 8 | `rule_cross_user_duplicate_url` | Non-repo URL submitted by a different user (copy-paste) | 1 | +| 9 | `rule_cross_user_identical_notes` | Same notes (>20 chars) used by a different user (farming) | 0* | + +**Current impact**: 135 out of 1,114 pending submissions (12.1%). + +*Rule 9 catches 0 currently because the farming templates have minor variations. + +### Anti-Gaming Detection (Analysis Findings) + +Deep analysis of the submission database revealed these gaming patterns: + +| Pattern | Pending affected | Detection method | +|---------|-----------------|------------------| +| No evidence at all | 220 (20.2%) | Caught by rules 1-2 | +| Same-user URL reuse | 62 (5.7%) | Caught by rules 5-6 | +| Previously-rejected URL resubmission | 28 | Caught by rule 5 | +| Cross-user identical tweet URLs | 11 | Caught by rule 8 | +| Blocklisted platform URLs | 11 | Caught by rule 7 | +| High-rejection users (80%+ rate, 5+ rejections) | 53 (4.9%) | Auto-ban + AI context | +| Sybil farm clusters (e.g., 5 "rahimi" accounts) | ~19 | Tier 2 AI context | +| Bulk submitters (4+ pending) | 332 (30.5%) | Tier 2 AI context | +| Submission velocity (5+/day) | 72 | Tier 2 AI context | + +### Blocklisted URLs + +Generic platform pages that are never valid evidence: +- `studio.genlayer.com/run-debug*` +- `studio.genlayer.com/contracts*` +- `points.genlayer.foundation*` +- `genlayer.com` / `www.genlayer.com` + +**Not included** in Tier 1 (by design): +- Duplicate GitHub repo URLs — can be resubmitted after new commits +- Contribution types with 0% acceptance — may accept in the future + +### Adding New Rules + +Add a function to `TIER1_RULES` in `review_submissions.py`: + +```python +def rule_my_new_rule(submission, evidence_items): + """Description of what this catches.""" + if some_condition: + return ('Template Label', 'CRM note explaining why') + return None + +TIER1_RULES = [ + ...existing rules..., + rule_my_new_rule, +] +``` + +## User Ban System + +### How It Works + +Users who repeatedly submit low-quality/spam content get automatically banned from submitting. They can appeal once via an in-app form. + +### Auto-Ban + +After each batch run, the `review_submissions` command checks for users meeting the auto-ban threshold: +- **5+ total rejections** AND **0 acceptances** (100% rejection rate) +- Sets `is_banned=True` with reason explaining the rejection count +- `banned_by` set to the AI steward user +- Runs at the end of each batch (not per-submission) +- **Current impact**: 46 users would be auto-banned from the production snapshot + +### Manual Ban + +Admins can ban/unban users via: +- **Admin panel**: `is_banned` field in User admin, plus bulk actions "Ban selected users" / "Unban selected users" +- **Future**: Steward UI for ban management + +### User Model Fields + +```python +# users/models.py +is_banned = BooleanField(default=False) +ban_reason = TextField(blank=True) # Shown to user +banned_at = DateTimeField(null=True) +banned_by = ForeignKey(User, null=True) # Who banned them +``` + +### Ban Appeal Flow + +1. Banned user sees ban status + reason on their profile (`GET /api/v1/users/me/` returns `is_banned`, `ban_reason`) +2. User submits **one-time appeal** via `POST /api/v1/users/me/appeal/` with `appeal_text` +3. System returns 409 if appeal already exists (one per user) +4. Stewards review appeals via `GET /api/v1/steward-submissions/ban-appeals/` (filterable by status) +5. Steward approves or denies via `POST /api/v1/steward-submissions/ban-appeals/{id}/review/` +6. If approved: user is unbanned (all ban fields cleared) +7. If denied: user stays banned, no further appeals possible + +### Ban Appeal Model + +```python +# users/models.py +class BanAppeal(BaseModel): + user = FK(User) + appeal_text = TextField + status = CharField: pending / approved / denied + reviewed_by = FK(User, null) + reviewed_at = DateTimeField(null) + review_notes = TextField(blank) +``` + +### API Endpoints + +| Endpoint | Method | Auth | Purpose | +|----------|--------|------|---------| +| `/api/v1/users/me/appeal/` | GET | User | Get appeal status | +| `/api/v1/users/me/appeal/` | POST | User | Submit appeal (banned users only, once) | +| `/api/v1/steward-submissions/ban-appeals/` | GET | Steward | List appeals (filter by ?status=pending) | +| `/api/v1/steward-submissions/ban-appeals/{id}/review/` | POST | Steward | Approve or deny appeal | + +### Submission Blocking + +Banned users get HTTP 403 when trying to create submissions (`SubmittedContributionViewSet.create()`): +```json +{"error": "Your account has been suspended due to repeated policy violations. You may submit one appeal from your profile page."} +``` + +## Tier 2: AI Classification (Claude via OpenRouter) + +### How It Works + +For each submission: +1. Builds context: notes, evidence URLs, contribution type, user history + rejection rate + spam signals +2. Loads few-shot examples from same contribution type (5 accepted + 5 rejected) +3. Calls Claude via OpenRouter API (with retry on parse errors, max 2 retries) +4. Returns structured JSON with action, template, confidence, reasoning, flags + +### Spam Signals (Fed to AI Context) + +The AI receives additional signals about suspicious patterns: +- **Submission velocity**: Warning if user submitted 5+ on the same day +- **Bulk submitter**: Warning if user has 8+ pending across N types +- **Suspicious email**: Warning if email contains "airdrop" +- **Rejection rate**: Shown with warning flag at 80%+ (3+ reviewed) + +### AI Response Schema + +```json +{ + "action": "accept | reject | more_info", + "template_id": 5, + "staff_reply": "Personalized message based on template", + "points": 3, + "confidence": "high | medium | low", + "reasoning": "Internal reasoning for CRM note", + "flags": ["ai_generated", "plagiarism", "low_effort", "wrong_type", "duplicate"] +} +``` + +### Decision Matrix + +| Confidence | Action | Result | +|-----------|--------|--------| +| HIGH | reject | Direct rejection (auto-applied) | +| HIGH | more_info | Direct more_info request (auto-applied) | +| HIGH | accept | Proposal (human review required) | +| MEDIUM/LOW | any | Proposal (human review required) | + +### Cost Tracking + +The command tracks and reports per-run: +- Input/output tokens +- Total USD cost +- Per-submission cost + +### Model Options + +| Model | Cost/submission | Quality | Use for | +|-------|----------------|---------|---------| +| `anthropic/claude-opus-4.6` | ~$0.023 | Best | Default, full review | +| `anthropic/claude-sonnet-4.6` | ~$0.012 | Good | Cost-effective alternative | +| `anthropic/claude-haiku-4.5` | ~$0.002 | Basic | Future pre-filter | + +### Tested Performance (100-submission batch) + +| Outcome | Count | % | +|---------|-------|---| +| Tier 1 auto-reject | 22 | 22% | +| Tier 2 HIGH reject (auto) | 37 | 37% | +| Tier 2 HIGH more_info (auto) | 5 | 5% | +| Tier 2 MEDIUM reject (proposal) | 16 | 16% | +| Tier 2 MEDIUM accept (proposal) | 18 | 18% | +| Tier 2 MEDIUM more_info (proposal) | 2 | 2% | + +**64% auto-actioned**, 36% proposals for human review. + +Tested across all contribution types: Educational Content, Tools & Infrastructure, Community Building, Blog Post, Documentation, Research & Analysis, Adversarial Testing, Projects & Milestones, 3rd Party Integrations. + +## Management Command Usage + +```bash +# Dry run (see what would happen) +python manage.py review_submissions --dry-run + +# Tier 1 only +python manage.py review_submissions --tier1-only + +# Tier 2 only +python manage.py review_submissions --tier2-only + +# Specific batch size +python manage.py review_submissions --batch-size 50 + +# Filter by contribution type +python manage.py review_submissions --type "Educational Content" + +# Single submission +python manage.py review_submissions --submission-id + +# Override model +python manage.py review_submissions --model anthropic/claude-sonnet-4.6 +``` + +## Environment Variables + +``` +OPENROUTER_API_KEY=sk-or-v1-... # Required for Tier 2 +``` + +## AWS Deployment (Planned) + +- **EventBridge** scheduled rule (e.g., every 6 hours) +- **ECS RunTask** on the existing App Runner container +- Runs: `python manage.py review_submissions --batch-size 100` +- `OPENROUTER_API_KEY` stored in SSM Parameter Store + +## Files + +| File | Purpose | +|------|---------| +| `contributions/management/commands/review_submissions.py` | Main management command | +| `stewards/migrations/0006_create_ai_steward.py` | AI steward user + permissions | +| `users/models.py` | `is_banned`, `ban_reason`, `banned_at`, `banned_by` fields + `BanAppeal` model | +| `users/migrations/0017_user_ban_fields.py` | Migration for ban system | +| `users/serializers.py` | `BanAppealSerializer`, `BanAppealReviewSerializer` | +| `users/views.py` | Appeal create/get endpoints | +| `users/admin.py` | Ban admin, BanAppeal admin, bulk ban/unban actions | +| `contributions/views.py` | Banned user submission block + steward appeal review endpoints | +| `leaderboard/models.py` | Fixed `raw=True` check in signals for loaddata | +| `.env` | `OPENROUTER_API_KEY` (gitignored) | + +## Data Analysis (Feb 25, 2026 snapshot) + +- **12,191 users**, **3,295 submissions** (1,087 pending) +- **593 pending** are "Educational Content" (mostly tweets) +- **135** auto-rejectable by Tier 1 rules (12.1%) +- **46** users auto-bannable (5+ rejections, 0 acceptances) +- **~640** auto-actionable by Tier 1 + Tier 2 HIGH (est. 64%) +- **~395** proposals for human review (est. 36%) +- Estimated total Tier 2 cost: ~$22 (979 submissions x $0.023) +- Top spam patterns: empty/short notes, URL reuse, previously-rejected URLs, blocklisted platform URLs +- October 2025 spam wave: 10x spike in submissions +- 87% of users have 0% acceptance rate +- Sybil farm detected: 5 "rahimi" accounts with identical submission patterns + +--- + +## Future Enhancements + +### URL Content Fetching + +Fetching actual content from evidence URLs to give the AI reviewer much richer context for classification. This is the highest-impact improvement for review accuracy. + +#### GitHub Repos + +**Goal**: Detect README-only repos, verify actual code exists, assess project quality. + +**Implementation plan**: +1. **API**: Use GitHub REST API (public, no auth needed for public repos; use OAuth token for higher rate limits) +2. **Data to fetch**: + - Repository metadata: stars, forks, language breakdown, created_at, last push date + - File tree: list of files (detect README-only repos with 0 code files) + - README.md content: extract for AI context + - Commit count and recent activity +3. **Signals to extract**: + - `is_readme_only`: True if repo has only README.md (or < 3 files total) + - `has_code`: True if any `.py`, `.js`, `.ts`, `.sol`, etc. files exist + - `commit_count`: Total commits (1 commit = likely generated) + - `last_push_days_ago`: Days since last push + - `language_breakdown`: Percentage of languages (detect empty/docs-only repos) +4. **Integration**: Call before AI classification, include in user message: + ``` + ## GitHub Repo Analysis + - Files: 3 (.py: 2, README.md: 1) + - Commits: 5 (last push: 2 days ago) + - Stars: 0, Forks: 0 + - README excerpt: (first 500 chars) + ``` +5. **Rate limiting**: GitHub allows 60 requests/hour unauthenticated, 5000/hour with token. At 100 submissions/batch, this is well within limits. + +#### Blog Posts (Medium, Dev.to, personal blogs) + +**Goal**: Verify article exists, assess depth and originality. + +**Implementation plan**: +1. **Method**: HTTP GET with appropriate User-Agent, parse HTML to extract article text +2. **Libraries**: `requests` + `beautifulsoup4` (already available) or `trafilatura` for article extraction +3. **Data to fetch**: + - Article title and full text + - Word count + - Publication date + - Author name (cross-check with submitter) +4. **Signals to extract**: + - `word_count`: Short articles (< 300 words) are likely low-effort + - `mentions_genlayer`: Does the article actually mention GenLayer? + - `is_accessible`: Can we actually reach the URL? + - `content_excerpt`: First 500 chars for AI context +5. **Edge cases**: Medium paywalled articles, deleted posts, geo-blocked content +6. **Integration**: Include article excerpt + metadata in AI prompt + +#### X/Twitter Posts + +**Goal**: Verify tweet exists, assess engagement and thread depth. + +**Implementation plan**: +1. **Method**: Use Twitter/X embed API (`publish.twitter.com/oembed`) or `nitter` instances for scraping +2. **Data to fetch**: + - Tweet text + - Is it a thread? (multiple tweets) + - Engagement: likes, retweets, replies (if available) + - Author handle (cross-check with submitter's twitter_handle) +3. **Signals to extract**: + - `is_thread`: Thread vs single tweet + - `tweet_length`: Character count + - `engagement_score`: likes + retweets + - `mentions_genlayer`: Does tweet mention GenLayer? +4. **Challenges**: X API is increasingly locked down. Alternatives: + - Twitter embed endpoint (free, returns HTML) + - Nitter instances (third-party, may be unreliable) + - Twitter API v2 (paid, $100/month minimum) +5. **Recommendation**: Start with embed API for basic tweet text, escalate to paid API only if needed + +#### Architecture + +``` +contributions/ + content_fetcher.py # URL content fetching module + models.py # Add EvidenceContent model for caching + +# EvidenceContent model +class EvidenceContent(BaseModel): + evidence = FK(Evidence) + url = URLField + content_type = CharField # github_repo, blog_post, tweet, unknown + raw_content = TextField # Fetched content + metadata = JSONField # Structured data (word_count, stars, etc.) + fetched_at = DateTimeField + fetch_error = TextField(blank) # Error message if fetch failed +``` + +**Caching strategy**: Fetch once per URL, store in `EvidenceContent`. Re-fetch if older than 7 days. Skip URLs that previously failed (retry after 30 days). + +**Cost impact**: No API costs (public endpoints). Adds ~0.5s per URL to processing time. Content included in AI prompt increases token count by ~$0.002/submission. + +### Embeddings-Based Similarity Detection + +**Goal**: Detect near-duplicate submissions across users (Sybil farms, copy-paste with minor word changes). + +**Implementation plan**: +1. **Embedding model**: Use OpenRouter to call an embedding model (e.g., `openai/text-embedding-3-small` at $0.02/1M tokens) +2. **What to embed**: Submission notes + fetched evidence content (once URL fetching is implemented) +3. **Storage options**: + - **pgvector** extension on PostgreSQL (production already uses Postgres) + - **SQLite vec** for local development + - **Separate vector DB** (Pinecone, Qdrant) if scale demands it +4. **Similarity search**: For each new submission, find top-5 most similar existing submissions +5. **Cross-user detection**: Flag when two different users have > 0.9 cosine similarity on notes +6. **Sybil cluster detection**: Build a similarity graph, find connected components with > 3 users + +**Estimated cost**: ~$0.50 to embed all 3,295 existing submissions. ~$0.001 per new submission. + +**Dependencies**: Requires URL content fetching to be most effective. Can start with notes-only embeddings. + +### Other Planned Improvements + +- [ ] Haiku pre-filter for cost optimization +- [ ] Frontend validation to prevent empty submissions at submission time +- [ ] Real-time Tier 1 rules on submission creation (reject before saving) +- [ ] Data migration for review templates +- [ ] Rate limiting at API level (flag at 5+ submissions/day, block at 10+) +- [ ] Sybil detection via email clustering + submission pattern overlap +- [ ] User reputation score displayed in steward review UI +- [ ] Steward UI for ban management (beyond admin panel) +- [ ] Frontend ban appeal form on user profile/dashboard diff --git a/backend/users/admin.py b/backend/users/admin.py index 7d340197..d03da3e5 100644 --- a/backend/users/admin.py +++ b/backend/users/admin.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from django.contrib import messages -from .models import User +from .models import BanAppeal, User from contributions.models import Contribution from validators.admin import ValidatorInline from builders.admin import BuilderInline @@ -46,8 +46,8 @@ def evidence_link(self, obj): @admin.register(User) class UserAdmin(BaseUserAdmin): - list_display = ('email', 'name', 'is_staff', 'is_active', 'visible', 'address', 'is_email_verified') - list_filter = ('is_staff', 'is_active', 'visible', 'is_email_verified') + list_display = ('email', 'name', 'is_staff', 'is_active', 'visible', 'is_banned', 'address', 'is_email_verified') + list_filter = ('is_staff', 'is_active', 'visible', 'is_banned', 'is_email_verified') search_fields = ('email', 'name', 'address', 'referral_code', 'twitter_handle', 'discord_handle', 'telegram_handle') ordering = ('email',) @@ -59,10 +59,11 @@ class UserAdmin(BaseUserAdmin): (_('GitHub Integration'), {'fields': ('github_username', 'github_user_id', 'github_linked_at')}), (_('Referral System'), {'fields': ('referral_code', 'referred_by')}), (_('Visibility'), {'fields': ('visible',)}), + (_('Ban Status'), {'fields': ('is_banned', 'ban_reason', 'banned_at', 'banned_by')}), (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), (_('Important dates'), {'fields': ('last_login', 'date_joined', 'created_at', 'updated_at')}), ) - readonly_fields = ('created_at', 'updated_at', 'profile_image_public_id', 'banner_image_public_id', 'referral_code', 'github_user_id', 'github_linked_at') + readonly_fields = ('created_at', 'updated_at', 'profile_image_public_id', 'banner_image_public_id', 'referral_code', 'github_user_id', 'github_linked_at', 'banned_at', 'banned_by') add_fieldsets = ( (None, { @@ -72,7 +73,7 @@ class UserAdmin(BaseUserAdmin): ) inlines = [ContributionInline, ValidatorInline, BuilderInline, StewardInline] - actions = ['set_as_builder', 'set_as_validator', 'set_as_steward', 'disconnect_github'] + actions = ['set_as_builder', 'set_as_validator', 'set_as_steward', 'disconnect_github', 'ban_users', 'unban_users'] def set_as_builder(self, request, queryset): """Action to set selected users as builders.""" @@ -198,3 +199,39 @@ def disconnect_github(self, request, queryset): if count > 0: self.message_user(request, f"Successfully disconnected GitHub from {count} user(s).", level=messages.SUCCESS) disconnect_github.short_description = "Disconnect GitHub accounts from selected users" + + def ban_users(self, request, queryset): + """Ban selected users from submitting contributions.""" + from django.utils import timezone + count = 0 + for user in queryset: + if user.is_banned: + self.message_user(request, f"{user.email} is already banned.", level=messages.WARNING) + continue + user.is_banned = True + user.ban_reason = 'Banned by admin.' + user.banned_at = timezone.now() + user.banned_by = request.user + user.save() + count += 1 + if count > 0: + self.message_user(request, f"Banned {count} user(s).", level=messages.SUCCESS) + ban_users.short_description = "Ban selected users" + + def unban_users(self, request, queryset): + """Unban selected users.""" + count = queryset.filter(is_banned=True).update( + is_banned=False, ban_reason='', banned_at=None, banned_by=None, + ) + if count > 0: + self.message_user(request, f"Unbanned {count} user(s).", level=messages.SUCCESS) + unban_users.short_description = "Unban selected users" + + +@admin.register(BanAppeal) +class BanAppealAdmin(admin.ModelAdmin): + list_display = ('user', 'status', 'created_at', 'reviewed_by', 'reviewed_at') + list_filter = ('status',) + search_fields = ('user__email', 'user__name', 'appeal_text') + readonly_fields = ('user', 'appeal_text', 'created_at', 'updated_at') + raw_id_fields = ('reviewed_by',) diff --git a/backend/users/migrations/0017_user_ban_fields.py b/backend/users/migrations/0017_user_ban_fields.py new file mode 100644 index 00000000..c1f65975 --- /dev/null +++ b/backend/users/migrations/0017_user_ban_fields.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.2 on 2026-02-25 12:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0016_add_github_fields'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='ban_reason', + field=models.TextField(blank=True, default='', help_text='Reason for the ban (shown to user)'), + ), + migrations.AddField( + model_name='user', + name='banned_at', + field=models.DateTimeField(blank=True, help_text='When the user was banned', null=True), + ), + migrations.AddField( + model_name='user', + name='banned_by', + field=models.ForeignKey(blank=True, help_text='User/steward who banned this user', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users_banned', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='user', + name='is_banned', + field=models.BooleanField(default=False, help_text='Whether this user is banned from submitting'), + ), + migrations.CreateModel( + name='BanAppeal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('appeal_text', models.TextField(help_text="User's explanation for why the ban should be lifted")), + ('status', models.CharField(choices=[('pending', 'Pending Review'), ('approved', 'Approved'), ('denied', 'Denied')], default='pending', max_length=20)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('review_notes', models.TextField(blank=True, default='', help_text='Internal notes from the reviewer')), + ('reviewed_by', models.ForeignKey(blank=True, help_text='Steward who reviewed this appeal', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_appeals', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ban_appeals', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index bdcb499d..3c25b12a 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -97,7 +97,18 @@ class User(AbstractUser, BaseModel): referred_by = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, related_name='referrals', help_text="User who referred this user") - + + # Ban system + is_banned = models.BooleanField(default=False, + help_text="Whether this user is banned from submitting") + ban_reason = models.TextField(blank=True, default='', + help_text="Reason for the ban (shown to user)") + banned_at = models.DateTimeField(null=True, blank=True, + help_text="When the user was banned") + banned_by = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, + related_name='users_banned', + help_text="User/steward who banned this user") + class Meta: constraints = [ models.UniqueConstraint( @@ -115,3 +126,31 @@ class Meta: def __str__(self): return self.email + + +class BanAppeal(BaseModel): + """ + One-time appeal from a banned user requesting their ban be lifted. + Each user can only submit one appeal. + """ + STATUS_CHOICES = [ + ('pending', 'Pending Review'), + ('approved', 'Approved'), + ('denied', 'Denied'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ban_appeals') + appeal_text = models.TextField(help_text="User's explanation for why the ban should be lifted") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + reviewed_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, + related_name='reviewed_appeals', + help_text="Steward who reviewed this appeal") + reviewed_at = models.DateTimeField(null=True, blank=True) + review_notes = models.TextField(blank=True, default='', + help_text="Internal notes from the reviewer") + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"Appeal by {self.user.email} ({self.status})" diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 7b96b30b..9f3b1b1f 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from disposable_email_domains import blocklist -from .models import User +from .models import BanAppeal, User from validators.models import Validator, ValidatorWallet from builders.models import Builder from stewards.models import Steward @@ -530,6 +530,8 @@ class Meta: 'description', 'banner_image_url', 'profile_image_url', 'website', 'twitter_handle', 'discord_handle', 'telegram_handle', 'linkedin_handle', 'github_username', 'github_linked_at', 'email', 'is_email_verified', + # Ban status + 'is_banned', 'ban_reason', # Referral fields 'referral_code', 'referred_by_info', 'total_referrals', 'referral_details', # Working groups @@ -767,10 +769,10 @@ def validate(self, data): def create(self, validated_data): # Remove password_confirm as it's not needed anymore validated_data.pop('password_confirm') - + # Get the visible field from the context if provided visible = self.context.get('visible', True) - + # Create user user = User.objects.create_user( email=validated_data['email'], @@ -779,5 +781,34 @@ def create(self, validated_data): address=validated_data.get('address', ''), visible=visible ) - - return user \ No newline at end of file + + return user + + +class BanAppealSerializer(serializers.ModelSerializer): + """Serializer for user ban appeals.""" + user_email = serializers.CharField(source='user.email', read_only=True) + user_name = serializers.CharField(source='user.name', read_only=True) + user_address = serializers.CharField(source='user.address', read_only=True) + reviewed_by_email = serializers.CharField( + source='reviewed_by.email', read_only=True, default=None, + ) + + class Meta: + model = BanAppeal + fields = [ + 'id', 'user', 'user_email', 'user_name', 'user_address', + 'appeal_text', 'status', + 'reviewed_by', 'reviewed_by_email', 'reviewed_at', 'review_notes', + 'created_at', 'updated_at', + ] + read_only_fields = [ + 'id', 'user', 'status', 'reviewed_by', 'reviewed_at', + 'review_notes', 'created_at', 'updated_at', + ] + + +class BanAppealReviewSerializer(serializers.Serializer): + """Serializer for steward review of ban appeals.""" + action = serializers.ChoiceField(choices=['approve', 'deny']) + review_notes = serializers.CharField(required=False, default='', allow_blank=True) \ No newline at end of file diff --git a/backend/users/tests/test_ban_system.py b/backend/users/tests/test_ban_system.py new file mode 100644 index 00000000..338fe92d --- /dev/null +++ b/backend/users/tests/test_ban_system.py @@ -0,0 +1,331 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework.test import APIClient +from rest_framework import status + +from users.models import BanAppeal + +User = get_user_model() + + +class BanModelTest(TestCase): + """Test ban fields on the User model and BanAppeal model.""" + + def setUp(self): + self.user = User.objects.create_user( + email='user@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.admin = User.objects.create_superuser( + email='admin@test.com', + address='0x0987654321098765432109876543210987654321', + password='testpass123', + ) + + def test_user_not_banned_by_default(self): + self.assertFalse(self.user.is_banned) + self.assertEqual(self.user.ban_reason, '') + self.assertIsNone(self.user.banned_at) + self.assertIsNone(self.user.banned_by) + + def test_ban_user(self): + self.user.is_banned = True + self.user.ban_reason = 'Repeated spam submissions' + self.user.banned_at = timezone.now() + self.user.banned_by = self.admin + self.user.save() + + self.user.refresh_from_db() + self.assertTrue(self.user.is_banned) + self.assertEqual(self.user.ban_reason, 'Repeated spam submissions') + self.assertIsNotNone(self.user.banned_at) + self.assertEqual(self.user.banned_by, self.admin) + + def test_unban_user(self): + self.user.is_banned = True + self.user.ban_reason = 'Spam' + self.user.banned_at = timezone.now() + self.user.banned_by = self.admin + self.user.save() + + self.user.is_banned = False + self.user.ban_reason = '' + self.user.banned_at = None + self.user.banned_by = None + self.user.save() + + self.user.refresh_from_db() + self.assertFalse(self.user.is_banned) + self.assertEqual(self.user.ban_reason, '') + + def test_create_ban_appeal(self): + self.user.is_banned = True + self.user.save() + + appeal = BanAppeal.objects.create( + user=self.user, + appeal_text='I promise to submit quality content.', + ) + self.assertEqual(appeal.status, 'pending') + self.assertIsNone(appeal.reviewed_by) + self.assertIsNone(appeal.reviewed_at) + self.assertEqual(str(appeal), f'Appeal by {self.user.email} (pending)') + + def test_approve_appeal(self): + self.user.is_banned = True + self.user.save() + + appeal = BanAppeal.objects.create( + user=self.user, + appeal_text='Please reconsider.', + ) + appeal.status = 'approved' + appeal.reviewed_by = self.admin + appeal.reviewed_at = timezone.now() + appeal.review_notes = 'User seems genuine.' + appeal.save() + + appeal.refresh_from_db() + self.assertEqual(appeal.status, 'approved') + self.assertEqual(appeal.reviewed_by, self.admin) + self.assertIsNotNone(appeal.reviewed_at) + + def test_deny_appeal(self): + self.user.is_banned = True + self.user.save() + + appeal = BanAppeal.objects.create( + user=self.user, + appeal_text='It was a mistake.', + ) + appeal.status = 'denied' + appeal.reviewed_by = self.admin + appeal.reviewed_at = timezone.now() + appeal.save() + + appeal.refresh_from_db() + self.assertEqual(appeal.status, 'denied') + + +class BanAppealAPITest(TestCase): + """Test the ban appeal API endpoints for users.""" + + def setUp(self): + self.user = User.objects.create_user( + email='user@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.client = APIClient() + + def test_get_appeal_not_banned(self): + """Non-banned user sees no appeal and can_appeal=False.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/v1/users/me/appeal/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data['appeal']) + self.assertFalse(response.data['can_appeal']) + + def test_get_appeal_banned_no_appeal_yet(self): + """Banned user sees can_appeal=True when no appeal exists.""" + self.user.is_banned = True + self.user.ban_reason = 'Spam' + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/v1/users/me/appeal/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data['appeal']) + self.assertTrue(response.data['can_appeal']) + + def test_submit_appeal(self): + """Banned user can submit an appeal.""" + self.user.is_banned = True + self.user.ban_reason = 'Spam' + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/users/me/appeal/', { + 'appeal_text': 'I will submit quality content from now on.', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['status'], 'pending') + self.assertEqual(BanAppeal.objects.filter(user=self.user).count(), 1) + + def test_cannot_submit_appeal_twice(self): + """Banned user cannot submit more than one appeal.""" + self.user.is_banned = True + self.user.save() + + BanAppeal.objects.create(user=self.user, appeal_text='First appeal') + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/users/me/appeal/', { + 'appeal_text': 'Second attempt', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(BanAppeal.objects.filter(user=self.user).count(), 1) + + def test_cannot_appeal_if_not_banned(self): + """Non-banned user cannot submit an appeal.""" + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/users/me/appeal/', { + 'appeal_text': 'Not banned but trying to appeal.', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_cannot_appeal_empty_text(self): + """Appeal requires non-empty text.""" + self.user.is_banned = True + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/v1/users/me/appeal/', { + 'appeal_text': '', + }, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_get_appeal_after_submission(self): + """After submitting, GET returns the appeal and can_appeal=False.""" + self.user.is_banned = True + self.user.save() + + BanAppeal.objects.create(user=self.user, appeal_text='My appeal') + + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/v1/users/me/appeal/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNotNone(response.data['appeal']) + self.assertFalse(response.data['can_appeal']) + self.assertEqual(response.data['appeal']['status'], 'pending') + + def test_unauthenticated_cannot_access_appeal(self): + """Unauthenticated users cannot access appeal endpoint.""" + response = self.client.get('/api/v1/users/me/appeal/') + self.assertIn(response.status_code, [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ]) + + def test_ban_fields_in_user_serializer(self): + """is_banned and ban_reason are included in /users/me/ response.""" + self.user.is_banned = True + self.user.ban_reason = 'Too much spam' + self.user.save() + + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/v1/users/me/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['is_banned']) + self.assertEqual(response.data['ban_reason'], 'Too much spam') + + +class BanAppealStewardReviewTest(TestCase): + """Test steward ban appeal review endpoints.""" + + def setUp(self): + self.banned_user = User.objects.create_user( + email='banned@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + is_banned=True, + ban_reason='Auto-banned: 10 rejections', + ) + self.steward_user = User.objects.create_user( + email='steward@test.com', + address='0x0987654321098765432109876543210987654321', + password='testpass123', + ) + from stewards.models import Steward + Steward.objects.create(user=self.steward_user) + + self.regular_user = User.objects.create_user( + email='regular@test.com', + address='0x1111111111111111111111111111111111111111', + password='testpass123', + ) + + self.appeal = BanAppeal.objects.create( + user=self.banned_user, + appeal_text='I have learned my lesson.', + ) + self.client = APIClient() + + def test_steward_can_list_appeals(self): + self.client.force_authenticate(user=self.steward_user) + response = self.client.get('/api/v1/steward-submissions/ban-appeals/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['status'], 'pending') + + def test_steward_can_filter_appeals_by_status(self): + self.client.force_authenticate(user=self.steward_user) + response = self.client.get('/api/v1/steward-submissions/ban-appeals/?status=pending') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + response = self.client.get('/api/v1/steward-submissions/ban-appeals/?status=approved') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + def test_steward_can_approve_appeal(self): + self.client.force_authenticate(user=self.steward_user) + response = self.client.post( + f'/api/v1/steward-submissions/ban-appeals/{self.appeal.id}/review/', + {'action': 'approve', 'review_notes': 'Seems genuine'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'approved') + + # Verify user is unbanned + self.banned_user.refresh_from_db() + self.assertFalse(self.banned_user.is_banned) + self.assertEqual(self.banned_user.ban_reason, '') + self.assertIsNone(self.banned_user.banned_at) + + def test_steward_can_deny_appeal(self): + self.client.force_authenticate(user=self.steward_user) + response = self.client.post( + f'/api/v1/steward-submissions/ban-appeals/{self.appeal.id}/review/', + {'action': 'deny', 'review_notes': 'Persistent spammer'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'denied') + + # Verify user stays banned + self.banned_user.refresh_from_db() + self.assertTrue(self.banned_user.is_banned) + + def test_cannot_review_already_reviewed_appeal(self): + self.appeal.status = 'denied' + self.appeal.save() + + self.client.force_authenticate(user=self.steward_user) + response = self.client.post( + f'/api/v1/steward-submissions/ban-appeals/{self.appeal.id}/review/', + {'action': 'approve'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_regular_user_cannot_list_appeals(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.get('/api/v1/steward-submissions/ban-appeals/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_regular_user_cannot_review_appeal(self): + self.client.force_authenticate(user=self.regular_user) + response = self.client.post( + f'/api/v1/steward-submissions/ban-appeals/{self.appeal.id}/review/', + {'action': 'approve'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_unauthenticated_cannot_list_appeals(self): + response = self.client.get('/api/v1/steward-submissions/ban-appeals/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/users/views.py b/backend/users/views.py index 6eff19e9..d6c8f5e4 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -6,8 +6,11 @@ from django.shortcuts import get_object_or_404 from django.conf import settings from django.db.models import Sum, Q -from .models import User -from .serializers import UserSerializer, UserCreateSerializer, UserProfileUpdateSerializer +from .models import BanAppeal, User +from .serializers import ( + BanAppealSerializer, UserSerializer, UserCreateSerializer, + UserProfileUpdateSerializer, +) from .cloudinary_service import CloudinaryService from .genlayer_service import GenLayerDeploymentService from contributions.models import Contribution @@ -799,3 +802,51 @@ def search(self, request): } for user in users ]) + + @action(detail=False, methods=['get', 'post'], permission_classes=[IsAuthenticated], + url_path='me/appeal') + def appeal(self, request): + """ + GET: Get current ban appeal status (if any). + POST: Submit a one-time ban appeal. Only allowed if user is banned + and has not already submitted an appeal. + """ + if request.method == 'GET': + appeal = BanAppeal.objects.filter(user=request.user).first() + if appeal is None: + return Response( + {'appeal': None, 'can_appeal': request.user.is_banned}, + ) + serializer = BanAppealSerializer(appeal) + return Response({ + 'appeal': serializer.data, + 'can_appeal': False, + }) + + # POST — submit appeal + if not request.user.is_banned: + return Response( + {'error': 'Your account is not banned.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if BanAppeal.objects.filter(user=request.user).exists(): + return Response( + {'error': 'You have already submitted an appeal. ' + 'Each user may only appeal once.'}, + status=status.HTTP_409_CONFLICT, + ) + + appeal_text = request.data.get('appeal_text', '').strip() + if not appeal_text: + return Response( + {'error': 'Please provide an explanation for your appeal.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + appeal = BanAppeal.objects.create( + user=request.user, + appeal_text=appeal_text, + ) + serializer = BanAppealSerializer(appeal) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte index 18d65d41..3179a751 100644 --- a/frontend/src/components/SubmissionCard.svelte +++ b/frontend/src/components/SubmissionCard.svelte @@ -47,6 +47,16 @@ // State for review form let reviewAction = $state(reviewData?.action || 'accept'); let proposedAction = $state('accept'); + + // Filter templates by action type + let acceptTemplates = $derived(templates.filter(t => t.action === 'accept')); + let rejectTemplates = $derived(templates.filter(t => t.action === 'reject')); + let moreInfoTemplates = $derived(templates.filter(t => t.action === 'more_info')); + let proposeContextTemplates = $derived( + reviewAction === 'propose' && proposedAction === 'reject' ? rejectTemplates : + reviewAction === 'propose' && proposedAction === 'more_info' ? moreInfoTemplates : + acceptTemplates + ); let selectedUser = $state(reviewData?.user || submission.user); let selectedType = $state(reviewData?.contribution_type || submission.contribution_type); let points = $state(reviewData?.points || submission.proposed_points || submission.contribution_type_details?.min_points || 0); @@ -54,6 +64,7 @@ let createHighlight = $state(reviewData?.create_highlight || false); let highlightTitle = $state(reviewData?.highlight_title || ''); let highlightDescription = $state(reviewData?.highlight_description || ''); + let selectedTemplateId = $state(null); // For ContributionSelection component let selectedCategory = $state(submission.contribution_type_details?.category || 'validator'); @@ -195,6 +206,7 @@ const template = templates.find(t => String(t.id) === templateId); if (template) { staffReply = template.text; + selectedTemplateId = template.id; } // Reset the select event.target.value = ''; @@ -210,7 +222,8 @@ staff_reply: staffReply, create_highlight: createHighlight, highlight_title: highlightTitle, - highlight_description: highlightDescription + highlight_description: highlightDescription, + template_id: selectedTemplateId }; onReview(submission.id, data); } @@ -221,6 +234,7 @@ const data = { proposed_action: proposedAction, proposed_staff_reply: staffReply, + template_id: selectedTemplateId, }; // Only include accept-specific fields when proposing accept @@ -649,13 +663,13 @@ - {#if templates.length > 0} + {#if proposeContextTemplates.length > 0} @@ -695,13 +709,13 @@ - {#if templates.length > 0} + {#if rejectTemplates.length > 0} @@ -730,13 +744,13 @@ - {#if templates.length > 0} + {#if moreInfoTemplates.length > 0} diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js index 16760c2d..b948ef96 100644 --- a/frontend/src/lib/searchParser.js +++ b/frontend/src/lib/searchParser.js @@ -114,7 +114,8 @@ export function parseSearch(query) { has: [], no: [], minContributions: null, - sort: null + sort: null, + freeText: [] }; if (!query || !query.trim()) { @@ -131,7 +132,9 @@ export function parseSearch(query) { const parsed = parseToken(token); if (!parsed) { - continue; // Ignore unrecognized tokens + // Untagged text — collect as free-text search terms + filters.freeText.push(token); + continue; } const { tag, value, negated } = parsed; diff --git a/frontend/src/lib/searchToParams.js b/frontend/src/lib/searchToParams.js index 6d761a64..f94e2a03 100644 --- a/frontend/src/lib/searchToParams.js +++ b/frontend/src/lib/searchToParams.js @@ -53,6 +53,11 @@ export function searchToParams(parsed, options = {}) { } } + // Free text (untagged) → search across username, notes, and evidence + if (filters.freeText && filters.freeText.length > 0 && !params.username_search) { + params.search = filters.freeText.join(' '); + } + // assigned → assigned_to if (filters.assigned) { const val = filters.assigned.value.toLowerCase(); diff --git a/frontend/src/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index 70794a35..02e0972a 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -36,6 +36,7 @@ // Permissions & templates let permissionsMap = $state({}); let templates = $state([]); + let rejectTemplates = $derived(templates.filter(t => t.action === 'reject')); // CRM Notes state - keyed by submission ID let submissionNotes = $state({}); @@ -537,7 +538,7 @@ - {#if templates.length > 0} + {#if rejectTemplates.length > 0}