diff --git a/FIXES_APPLIED.md b/FIXES_APPLIED.md deleted file mode 100644 index 1069e20..0000000 --- a/FIXES_APPLIED.md +++ /dev/null @@ -1,350 +0,0 @@ -# Critical Issues - Fixes Applied - -**Date:** $(date) -**Status:** ✅ All Critical Issues Fixed - -## Summary - -All 6 critical security and configuration issues have been successfully fixed. The application is now more secure and follows best practices for production deployment. - ---- - -## ✅ Fix #1: Hardcoded Secrets Removed - -**File:** `backend/app/__init__.py` -**Lines:** 83-84 → Updated - -### What Changed -- Removed insecure default secrets (`'rohank10'`) -- Added secure secret generation for development (with warnings) -- Requires environment variables for production - -### Code Before -```python -flask_app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'rohank10') -flask_app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'rohank10') -``` - -### Code After -```python -# CRITICAL: Require environment variables - no insecure defaults -secret_key = os.getenv('SECRET_KEY') -jwt_secret_key = os.getenv('JWT_SECRET_KEY') - -# For development only: generate temporary secrets if not set (with warning) -if not secret_key or not jwt_secret_key: - import secrets - if not secret_key: - secret_key = secrets.token_urlsafe(48) - print("⚠️ WARNING: SECRET_KEY not set in environment...") - if not jwt_secret_key: - jwt_secret_key = secrets.token_urlsafe(48) - print("⚠️ WARNING: JWT_SECRET_KEY not set in environment...") - -flask_app.config['SECRET_KEY'] = secret_key -flask_app.config['JWT_SECRET_KEY'] = jwt_secret_key -``` - -### Impact -- ✅ No insecure defaults -- ✅ Secure random secrets generated for development -- ✅ Clear warnings if environment variables not set -- ✅ Production-ready (requires env vars) - ---- - -## ✅ Fix #2: Debug Endpoint Secured - -**File:** `backend/app/__init__.py` -**Lines:** 373-400 → Updated - -### What Changed -- Added `@jwt_required()` decorator -- Added admin role check -- Updated documentation to reflect security requirements - -### Code Before -```python -@app.route('/api/debug/user-role/', methods=['GET']) -def debug_user_role(username): - # No authentication - exposes user data! -``` - -### Code After -```python -@app.route('/api/debug/user-role/', methods=['GET']) -@jwt_required() -def debug_user_role(username): - """ - SECURITY: Requires authentication and admin role to prevent information disclosure. - Only admins can access this debug endpoint. - """ - # Verify user is authenticated and is admin - try: - user_id = get_jwt_identity() - if not user_id: - return jsonify({'error': 'Authentication required'}), 401 - - current_user = User.query.get(int(user_id)) - if not current_user or not current_user.is_admin(): - return jsonify({'error': 'Admin access required'}), 403 - except Exception: - return jsonify({'error': 'Authentication required'}), 401 -``` - -### Impact -- ✅ Endpoint now requires authentication -- ✅ Only admins can access debug information -- ✅ Prevents information disclosure attacks -- ✅ User enumeration prevented - ---- - -## ✅ Fix #3: Debug Mode Controlled by Environment - -**Files:** -- `backend/app/__init__.py` (line 977-981) -- `backend/main.py` (line 23-27) - -### What Changed -- Debug mode now controlled by `FLASK_DEBUG` environment variable -- Defaults to `False` for production safety -- Clear warnings when debug mode is enabled - -### Code Before -```python -app.run( - debug=True, # ⚠️ Hardcoded! - host='0.0.0.0', - port=5000 -) -``` - -### Code After -```python -# Debug mode is controlled by environment variable (FLASK_DEBUG) for security -# Default to False for production safety -debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() == 'true' -if debug_mode: - print("⚠️ WARNING: Debug mode is enabled. Disable in production!") - -app.run( - debug=debug_mode, # Controlled by FLASK_DEBUG environment variable - host='0.0.0.0', - port=5000 -) -``` - -### Impact -- ✅ Debug mode disabled by default -- ✅ Controlled via environment variable -- ✅ Production-safe defaults -- ✅ Clear warnings when enabled - ---- - -## ✅ Fix #4: Database Password Default Removed - -**File:** `backend/app/config.py` -**Lines:** 52-55 → Updated - -### What Changed -- Removed weak default password (`'password'`) -- Uses SQLite for development (no password needed) -- Requires `DATABASE_URL` for production - -### Code Before -```python -SQLALCHEMY_DATABASE_URI = os.getenv( - 'DATABASE_URL', - 'mysql+pymysql://root:password@localhost/wikicontest' # ⚠️ Weak default -) -``` - -### Code After -```python -# Database connection string -# For development: uses SQLite (no password needed) -# For production: DATABASE_URL must be set in environment -# CRITICAL: No default password - use SQLite for development or require DATABASE_URL -database_url = os.getenv('DATABASE_URL') -if not database_url: - # Development fallback: use SQLite (no password, easier setup) - database_url = 'sqlite:///wikicontest_dev.db' - print("⚠️ WARNING: DATABASE_URL not set. Using SQLite for development.") - print(" Set DATABASE_URL in environment for production!") - -SQLALCHEMY_DATABASE_URI = database_url -``` - -### Impact -- ✅ No weak default passwords -- ✅ SQLite for development (easier, no password) -- ✅ Production requires explicit DATABASE_URL -- ✅ Clear warnings for configuration - ---- - -## ✅ Fix #5: Error Handler Added to Update Route - -**File:** `backend/app/routes/contest_routes.py` -**Line:** 690-692 → Updated - -### What Changed -- Added `@handle_errors` decorator for consistent error handling - -### Code Before -```python -@contest_bp.route("/", methods=["PUT"]) -@require_auth -# ⚠️ Missing @handle_errors -def update_contest(contest_id): -``` - -### Code After -```python -@contest_bp.route("/", methods=["PUT"]) -@require_auth -@handle_errors # ✅ Added for consistent error handling -def update_contest(contest_id): -``` - -### Impact -- ✅ Consistent error handling across all routes -- ✅ Prevents information leakage from unhandled exceptions -- ✅ Better user experience with proper error messages - ---- - -## ✅ Fix #6: OAuth Config Files Added to .gitignore - -**File:** `.gitignore` -**Lines:** 163-167 → Updated - -### What Changed -- Added OAuth configuration files to `.gitignore` -- Prevents accidental commit of secrets - -### Code Added -```gitignore -# Configuration files with secrets -config.ini -secrets.json -.secrets -*.toml -toolforge_config.toml -backend/toolforge/toolforge_config.toml -``` - -### Impact -- ✅ OAuth secrets protected from version control -- ✅ Prevents accidental exposure of credentials -- ✅ Follows security best practices - -### ⚠️ IMPORTANT: Action Required - -**You must still:** -1. **Revoke current OAuth consumer** at: https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration -2. **Create new OAuth consumer** with new credentials -3. **Update environment variables** with new credentials -4. **Remove old credentials** from `backend/toolforge/toolforge_config.toml` (if committed) - ---- - -## Testing Checklist - -After these fixes, verify: - -- [x] No hardcoded secrets in codebase -- [x] Debug endpoint requires authentication -- [x] Debug mode controlled by environment variable -- [x] Database connection uses SQLite for dev or requires DATABASE_URL -- [x] All routes have consistent error handling -- [x] OAuth config files in .gitignore - -### Manual Testing Steps - -1. **Test Secret Generation:** - ```bash - # Without env vars - should generate secrets with warnings - python backend/main.py - ``` - -2. **Test Debug Endpoint:** - ```bash - # Should return 401 without auth - curl http://localhost:5000/api/debug/user-role/testuser - - # Should return 403 for non-admin users - # Should return 200 for admin users - ``` - -3. **Test Debug Mode:** - ```bash - # Debug mode disabled by default - python backend/main.py - - # Enable debug mode - FLASK_DEBUG=true python backend/main.py - ``` - -4. **Test Database:** - ```bash - # Should use SQLite if DATABASE_URL not set - python backend/main.py - ``` - ---- - -## Environment Variables Required - -For production deployment, set these environment variables: - -```bash -# Required for production -SECRET_KEY= -JWT_SECRET_KEY= -DATABASE_URL= - -# Optional (defaults to False) -FLASK_DEBUG=false - -# OAuth (if using OAuth) -CONSUMER_KEY= -CONSUMER_SECRET= -``` - -### Generate Secure Secrets - -```bash -# Generate SECRET_KEY -python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_urlsafe(48))" - -# Generate JWT_SECRET_KEY -python3 -c "import secrets; print('JWT_SECRET_KEY=' + secrets.token_urlsafe(48))" -``` - ---- - -## Notes - -- All fixes are backward compatible -- Development workflow unchanged (uses SQLite, generates secrets) -- Production requires explicit configuration (as it should) -- No breaking changes to existing functionality -- All linting checks pass - ---- - -## Next Steps - -1. ✅ Review all changes -2. ✅ Test in development environment -3. ⚠️ Revoke and regenerate OAuth credentials (if exposed) -4. ✅ Update production environment variables -5. ✅ Deploy to production -6. ✅ Monitor for any issues - ---- - -**All critical security issues have been resolved!** 🎉 diff --git a/backend/alembic/versions/d55c876a1323_add_outreach_dashboard_url_column_to_.py b/backend/alembic/versions/d55c876a1323_add_outreach_dashboard_url_column_to_.py new file mode 100644 index 0000000..35fe165 --- /dev/null +++ b/backend/alembic/versions/d55c876a1323_add_outreach_dashboard_url_column_to_.py @@ -0,0 +1,27 @@ +"""Add outreach_dashboard_url column to contests table + +Revision ID: d55c876a1323 +Revises: de4074ff4ff8 +Create Date: 2026-01-30 17:47:10.503823 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd55c876a1323' +down_revision = 'de4074ff4ff8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add outreach_dashboard_url column to contests table + op.add_column('contests', sa.Column('outreach_dashboard_url', sa.Text(), nullable=True)) + + +def downgrade() -> None: + # Remove outreach_dashboard_url column from contests table + op.drop_column('contests', 'outreach_dashboard_url') + diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 0eb9b3e..544b670 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -100,6 +100,10 @@ class Contest(BaseModel, ContestMixin): # Used to enforce template attachment on submitted articles template_link = db.Column(db.Text, nullable=True) + # Outreach Dashboard URL (base URL for Outreach Dashboard course) + # Used to link contest with Outreach Dashboard course data + outreach_dashboard_url = db.Column(db.Text, nullable=True) + # Contest organizers who can manage the contest (comma-separated usernames) # Creator is always included as an organizer organizers = db.Column(db.Text, nullable=True) @@ -158,6 +162,9 @@ def __init__(self, name, project_name, created_by, **kwargs): # Set template link (optional) self.template_link = kwargs.get("template_link") + # Set Outreach Dashboard URL (optional) + self.outreach_dashboard_url = kwargs.get("outreach_dashboard_url") + # Set complex fields using setter methods (handle JSON/list conversion) self.set_categories(kwargs.get("categories", [])) self.set_rules(kwargs.get("rules", {})) @@ -594,6 +601,7 @@ def to_dict(self): "jury_members": self.get_jury_members(), "organizers": self.get_organizers(), "template_link": self.template_link, + "outreach_dashboard_url": self.outreach_dashboard_url, "created_at": ( (self.created_at.isoformat() + "Z") if self.created_at else None ), diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index 6001154..b5dbf6c 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -32,6 +32,13 @@ get_article_reference_count, MEDIAWIKI_API_TIMEOUT, ) +from app.services.outreach_dashboard import ( + validate_outreach_url, + fetch_course_data, + fetch_course_users, + fetch_course_articles, + fetch_course_uploads, +) # ------------------------------------------------------------------------ @@ -128,6 +135,152 @@ def get_all_contests(): return jsonify({"current": current, "upcoming": upcoming, "past": past}), 200 +@contest_bp.route("//outreach-data", methods=["GET"]) +@require_auth +@handle_errors +def get_contest_outreach_data(contest_id): + """ + Get Outreach Dashboard course data for a contest + + Requires authentication - users must be logged in to view contest details. + + Args: + contest_id: Contest ID + + Returns: + JSON response with Outreach Dashboard course data or error message + """ + contest = Contest.query.get(contest_id) + + if not contest: + return jsonify({"error": "Contest not found"}), 404 + + if not contest.outreach_dashboard_url: + return jsonify({"error": "Contest does not have an Outreach Dashboard URL"}), 400 + + # Fetch course data from Outreach Dashboard API + result = fetch_course_data(contest.outreach_dashboard_url) + + if result["success"]: + return jsonify({ + "success": True, + "data": result["data"] + }), 200 + else: + return jsonify({ + "success": False, + "error": result["error"] + }), 400 + + +@contest_bp.route("//outreach-users", methods=["GET"]) +@require_auth +@handle_errors +def get_outreach_dashboard_users(contest_id): + """ + Fetch Outreach Dashboard course users data for a contest. + + Args: + contest_id: ID of the contest + + Returns: + JSON response with Outreach Dashboard course users data or error message + """ + contest = Contest.query.get(contest_id) + + if not contest: + return jsonify({"error": "Contest not found"}), 404 + + if not contest.outreach_dashboard_url: + return jsonify({"error": "Contest does not have an Outreach Dashboard URL"}), 400 + + # Fetch course users data from Outreach Dashboard API + result = fetch_course_users(contest.outreach_dashboard_url) + + if result["success"]: + return jsonify({ + "success": True, + "data": result["data"] + }), 200 + else: + return jsonify({ + "success": False, + "error": result["error"] + }), 400 + + +@contest_bp.route("//outreach-articles", methods=["GET"]) +@require_auth +@handle_errors +def get_outreach_dashboard_articles(contest_id): + """ + Fetch Outreach Dashboard course articles data for a contest. + + Args: + contest_id: ID of the contest + + Returns: + JSON response with Outreach Dashboard course articles data or error message + """ + contest = Contest.query.get(contest_id) + + if not contest: + return jsonify({"error": "Contest not found"}), 404 + + if not contest.outreach_dashboard_url: + return jsonify({"error": "Contest does not have an Outreach Dashboard URL"}), 400 + + # Fetch course articles data from Outreach Dashboard API + result = fetch_course_articles(contest.outreach_dashboard_url) + + if result["success"]: + return jsonify({ + "success": True, + "data": result["data"] + }), 200 + else: + return jsonify({ + "success": False, + "error": result["error"] + }), 400 + + +@contest_bp.route("//outreach-uploads", methods=["GET"]) +@require_auth +@handle_errors +def get_outreach_dashboard_uploads(contest_id): + """ + Fetch Outreach Dashboard course uploads data for a contest. + + Args: + contest_id: ID of the contest + + Returns: + JSON response with Outreach Dashboard course uploads data or error message + """ + contest = Contest.query.get(contest_id) + + if not contest: + return jsonify({"error": "Contest not found"}), 404 + + if not contest.outreach_dashboard_url: + return jsonify({"error": "Contest does not have an Outreach Dashboard URL"}), 400 + + # Fetch course uploads data from Outreach Dashboard API + result = fetch_course_uploads(contest.outreach_dashboard_url) + + if result["success"]: + return jsonify({ + "success": True, + "data": result["data"] + }), 200 + else: + return jsonify({ + "success": False, + "error": result["error"] + }), 400 + + @contest_bp.route("/", methods=["GET"]) @require_auth @handle_errors @@ -610,6 +763,26 @@ def create_contest(): else: template_link = None # Empty string becomes None + # Parse outreach_dashboard_url (optional) + outreach_dashboard_url = data.get("outreach_dashboard_url") + if outreach_dashboard_url: + outreach_dashboard_url = outreach_dashboard_url.strip() + if outreach_dashboard_url: # Non-empty after strip + validation_result = validate_outreach_url(outreach_dashboard_url) + if not validation_result["valid"]: + return ( + jsonify( + { + "error": f"Invalid Outreach Dashboard URL: {validation_result['error']}" + } + ), + 400, + ) + else: + outreach_dashboard_url = None # Empty string becomes None + else: + outreach_dashboard_url = None + # Create contest try: # Parse additional organizers (creator is automatically added) @@ -633,6 +806,7 @@ def create_contest(): min_byte_count=min_byte_count, categories=categories, template_link=template_link, + outreach_dashboard_url=outreach_dashboard_url, scoring_parameters=scoring_parameters, organizers=additional_organizers, min_reference_count=min_reference_count, @@ -930,6 +1104,28 @@ def update_contest(contest_id): else: contest.template_link = None # None clears the field + # --- Outreach Dashboard URL --- + if "outreach_dashboard_url" in data: + outreach_url_value = data.get("outreach_dashboard_url") + if outreach_url_value: + outreach_url_value = outreach_url_value.strip() + if outreach_url_value: # Non-empty after strip + validation_result = validate_outreach_url(outreach_url_value) + if not validation_result["valid"]: + return ( + jsonify( + { + "error": f"Invalid Outreach Dashboard URL: {validation_result['error']}" + } + ), + 400, + ) + contest.outreach_dashboard_url = outreach_url_value + else: + contest.outreach_dashboard_url = None # Empty string clears the field + else: + contest.outreach_dashboard_url = None # None clears the field + # --- Jury Members --- # Accept both list and comma-separated string formats if "jury_members" in data: diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..5333d0d --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,23 @@ +""" +Services package for WikiContest Application +""" + +from .outreach_dashboard import ( + parse_outreach_url, + validate_outreach_url, + fetch_course_data, + fetch_course_users, + fetch_course_articles, + fetch_course_uploads, + build_course_api_url +) + +__all__ = [ + 'parse_outreach_url', + 'validate_outreach_url', + 'fetch_course_data', + 'fetch_course_users', + 'fetch_course_articles', + 'fetch_course_uploads', + 'build_course_api_url' +] diff --git a/backend/app/services/outreach_dashboard.py b/backend/app/services/outreach_dashboard.py new file mode 100644 index 0000000..228fbb9 --- /dev/null +++ b/backend/app/services/outreach_dashboard.py @@ -0,0 +1,562 @@ +""" +Outreach Dashboard API Service + +This module provides functions to interact with Wikimedia's Outreach Dashboard API. +It handles URL parsing, validation, and fetching data from various endpoints. +Designed to be extensible for future API endpoints. +""" + +import re +from typing import Dict, Optional, Any, List +from urllib.parse import urlparse, urljoin + +import requests +from flask import current_app + + +# Base URL for Outreach Dashboard API +OUTREACH_DASHBOARD_BASE = "https://outreachdashboard.wmflabs.org" +API_TIMEOUT = 10 # seconds + + +def parse_outreach_url(url: str) -> Dict[str, Optional[str]]: + """ + Parse an Outreach Dashboard URL to extract school and course_slug. + + Accepts URLs in the format: + - https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug} + - https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}/ + - https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}/course.json + - https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}/home + - https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}/enroll + - And other paths (automatically stripped) + + Args: + url: The Outreach Dashboard URL to parse + + Returns: + Dictionary with keys: + - 'school': School/institution name (or None if not found) + - 'course_slug': Course slug (or None if not found) + - 'valid': Boolean indicating if URL format is valid + """ + if not url or not isinstance(url, str): + return {'school': None, 'course_slug': None, 'valid': False} + + url = url.strip() + + # Parse the URL + try: + parsed = urlparse(url) + except Exception: + return {'school': None, 'course_slug': None, 'valid': False} + + # Check if it's an Outreach Dashboard URL + if 'outreachdashboard.wmflabs.org' not in parsed.netloc: + return {'school': None, 'course_slug': None, 'valid': False} + + # Extract path components + path = parsed.path.strip('/') + + # Split path into components and take only the first 3 parts + # This handles URLs with suffixes like /home, /enroll, /course.json, etc. + # Example: courses/school/course/home -> ['courses', 'school', 'course', 'home'] + # We only need: ['courses', 'school', 'course'] + path_parts = path.split('/') + + # We need at least 3 parts: courses, school, course_slug + if len(path_parts) < 3: + return {'school': None, 'course_slug': None, 'valid': False} + + # Check if it starts with 'courses' + if path_parts[0] != 'courses': + return {'school': None, 'course_slug': None, 'valid': False} + + # Extract school and course_slug (ignore any additional path segments) + school = path_parts[1] + course_slug = path_parts[2] + + # Validate that school and course_slug are not empty + if not school or not course_slug: + return {'school': None, 'course_slug': None, 'valid': False} + + return { + 'school': school, + 'course_slug': course_slug, + 'valid': True + } + + +def validate_outreach_url(url: str) -> Dict[str, Any]: + """ + Validate an Outreach Dashboard URL format. + + Args: + url: The URL to validate + + Returns: + Dictionary with keys: + - 'valid': Boolean indicating if URL is valid + - 'error': Error message if invalid (None if valid) + """ + if not url or not isinstance(url, str): + return {'valid': False, 'error': 'URL is required'} + + url = url.strip() + + if not url: + return {'valid': False, 'error': 'URL cannot be empty'} + + # Check basic URL format + if not (url.startswith('http://') or url.startswith('https://')): + return {'valid': False, 'error': 'URL must start with http:// or https://'} + + # Parse the URL + parsed = parse_outreach_url(url) + + if not parsed['valid']: + return { + 'valid': False, + 'error': 'Invalid Outreach Dashboard URL format. Expected: https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}' + } + + return {'valid': True, 'error': None} + + +def _build_api_url(school: str, course_slug: str, endpoint: str) -> str: + """ + Build API URL for any Outreach Dashboard endpoint. + + Args: + school: School/institution name + course_slug: Course slug identifier + endpoint: API endpoint name (e.g., 'course.json', 'users.json') + + Returns: + Full API URL string + """ + return f"{OUTREACH_DASHBOARD_BASE}/courses/{school}/{course_slug}/{endpoint}" + + +def _make_api_request(api_url: str) -> Dict[str, Any]: + """ + Make HTTP request to Outreach Dashboard API with standardized error handling. + + Args: + api_url: Full API URL to request + + Returns: + Dictionary with keys: + - 'success': Boolean indicating if request was successful + - 'data': Parsed JSON data if successful (None otherwise) + - 'error': Error message if failed (None if successful) + """ + try: + # Make request to Outreach Dashboard API + response = requests.get(api_url, timeout=API_TIMEOUT) + + if response.status_code == 404: + return { + 'success': False, + 'data': None, + 'error': 'Resource not found. Please verify the URL is correct.' + } + + if response.status_code != 200: + return { + 'success': False, + 'data': None, + 'error': f'API returned status code {response.status_code}' + } + + # Parse JSON response + try: + data = response.json() + except ValueError as e: + return { + 'success': False, + 'data': None, + 'error': f'Failed to parse API response: {str(e)}' + } + + return { + 'success': True, + 'data': data, + 'error': None + } + + except requests.exceptions.Timeout: + return { + 'success': False, + 'data': None, + 'error': 'Request timed out. The Outreach Dashboard API may be slow or unavailable.' + } + except requests.exceptions.ConnectionError: + return { + 'success': False, + 'data': None, + 'error': 'Failed to connect to Outreach Dashboard API. Please check your internet connection.' + } + except Exception as e: + current_app.logger.error(f"Error fetching Outreach Dashboard data: {str(e)}") + return { + 'success': False, + 'data': None, + 'error': f'Unexpected error: {str(e)}' + } + + +def fetch_course_data(base_url: str) -> Dict[str, Any]: + """ + Fetch course data from Outreach Dashboard API. + + Args: + base_url: Base URL of the course (without /course.json) + + Returns: + Dictionary with keys: + - 'success': Boolean indicating if fetch was successful + - 'data': Course data dictionary if successful (None otherwise) + - 'error': Error message if failed (None if successful) + """ + if not base_url or not isinstance(base_url, str): + return { + 'success': False, + 'data': None, + 'error': 'Base URL is required' + } + + base_url = base_url.strip() + + # Remove common suffixes that users might include + # This handles cases like /home, /enroll, /course.json, etc. + common_suffixes = ['/home', '/enroll', '/course.json', '/students', '/articles', '/timeline'] + for suffix in common_suffixes: + if base_url.endswith(suffix): + base_url = base_url[:-len(suffix)] + break + + base_url = base_url.rstrip('/') + + # Parse URL to get school and course_slug + parsed = parse_outreach_url(base_url) + if not parsed['valid']: + return { + 'success': False, + 'data': None, + 'error': 'Invalid Outreach Dashboard URL format' + } + + # Build API URL using helper function + api_url = _build_api_url(parsed['school'], parsed['course_slug'], 'course.json') + + # Make API request using base handler + result = _make_api_request(api_url) + + if not result['success']: + return result + + # Extract course data from response + data = result['data'] + if 'course' in data: + return { + 'success': True, + 'data': data['course'], + 'error': None + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: missing "course" key' + } + + +def fetch_course_users(base_url: str) -> Dict[str, Any]: + """ + Fetch course users data from Outreach Dashboard API. + + Returns an array of user objects with enrollment details, contribution metrics, + and links to Wikimedia profiles. + + Args: + base_url: Base URL of the course (without /users.json) + + Returns: + Dictionary with keys: + - 'success': Boolean indicating if fetch was successful + - 'data': List of user objects if successful (None otherwise) + - 'error': Error message if failed (None if successful) + + User objects include important fields: + - id: Unique Wikimedia user ID + - username: Wikimedia username + - role: User's role (0 for student, 1 for instructor) + - enrolled_at: Enrollment timestamp (ISO 8601) + - character_sum_ms: Characters added to mainspace + - character_sum_us: Characters added to userspace + - total_uploads: Number of files uploaded + - contribution_url: URL to user's contributions + - sandbox_url: URL to user's sandbox + - global_contribution_url: URL to global contributions + - admin: Boolean indicating admin rights + """ + if not base_url or not isinstance(base_url, str): + return { + 'success': False, + 'data': None, + 'error': 'Base URL is required' + } + + base_url = base_url.strip() + + # Remove common suffixes that users might include + common_suffixes = ['/home', '/enroll', '/users.json', '/course.json', '/students', '/articles', '/timeline'] + for suffix in common_suffixes: + if base_url.endswith(suffix): + base_url = base_url[:-len(suffix)] + break + + base_url = base_url.rstrip('/') + + # Parse URL to get school and course_slug + parsed = parse_outreach_url(base_url) + if not parsed['valid']: + return { + 'success': False, + 'data': None, + 'error': 'Invalid Outreach Dashboard URL format' + } + + # Build API URL using helper function + api_url = _build_api_url(parsed['school'], parsed['course_slug'], 'users.json') + + # Make API request using base handler + result = _make_api_request(api_url) + + if not result['success']: + return result + + # Extract users data from response + # Response structure: {'course': {'users': [...]}} + data = result['data'] + if 'course' in data and 'users' in data['course']: + users = data['course']['users'] + # Ensure it's a list (API may return empty array) + if isinstance(users, list): + return { + 'success': True, + 'data': users, + 'error': None + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: "users" is not an array' + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: missing "course.users" key' + } + + +def fetch_course_articles(base_url: str) -> Dict[str, Any]: + """ + Fetch course articles data from Outreach Dashboard API. + + Returns an array of article objects with contribution metrics, visibility stats, + and links to Wikimedia articles. + + Args: + base_url: Base URL of the course (without /articles.json) + + Returns: + Dictionary with keys: + - 'success': Boolean indicating if fetch was successful + - 'data': List of article objects if successful (None otherwise) + - 'error': Error message if failed (None if successful) + + Article objects include important fields: + - id: Unique identifier for the article revision or tracking entry + - mw_page_id: MediaWiki page ID on the wiki + - title: Article title + - language: Wiki language code + - project: Wikimedia project (e.g., "wikimedia", "wikipedia") + - namespace: Wiki namespace (0 for main articles) + - new_article: Boolean indicating if article was newly created + - tracked: Boolean indicating if article is monitored by dashboard + - user_ids: Array of user IDs who contributed to this article + - character_sum: Total characters added or changed + - view_count: Total page views during tracking period + - average_views: Average daily views + - url: Full URL to the article + """ + if not base_url or not isinstance(base_url, str): + return { + 'success': False, + 'data': None, + 'error': 'Base URL is required' + } + + base_url = base_url.strip() + + # Remove common suffixes that users might include + common_suffixes = ['/home', '/enroll', '/users.json', '/course.json', '/articles.json', '/students', '/articles', '/timeline'] + for suffix in common_suffixes: + if base_url.endswith(suffix): + base_url = base_url[:-len(suffix)] + break + + base_url = base_url.rstrip('/') + + # Parse URL to get school and course_slug + parsed = parse_outreach_url(base_url) + if not parsed['valid']: + return { + 'success': False, + 'data': None, + 'error': 'Invalid Outreach Dashboard URL format' + } + + # Build API URL using helper function + api_url = _build_api_url(parsed['school'], parsed['course_slug'], 'articles.json') + + # Make API request using base handler + result = _make_api_request(api_url) + + if not result['success']: + return result + + # Extract articles data from response + # Response structure: {'course': {'articles': [...]}} + data = result['data'] + if 'course' in data and 'articles' in data['course']: + articles = data['course']['articles'] + # Ensure it's a list (API may return empty array) + if isinstance(articles, list): + return { + 'success': True, + 'data': articles, + 'error': None + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: "articles" is not an array' + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: missing "course.articles" key' + } + + +def fetch_course_uploads(base_url: str) -> Dict[str, Any]: + """ + Fetch course uploads data from Outreach Dashboard API. + + Returns an array of upload objects with media file details, usage statistics, + and links to Wikimedia Commons. + + Args: + base_url: Base URL of the course (without /uploads.json) + + Returns: + Dictionary with keys: + - 'success': Boolean indicating if fetch was successful + - 'data': List of upload objects if successful (None otherwise) + - 'error': Error message if failed (None if successful) + + Upload objects include important fields: + - id: Unique identifier for the upload + - file_name: Original filename of the uploaded media + - uploader: Username of the Wikimedia user who uploaded the file + - uploaded_at: Timestamp of the upload (ISO 8601) + - deleted: Boolean indicating if the upload has been deleted + - usage_count: Number of times the file is used in Wikimedia pages (null if untracked) + - url: Full URL to the file on Wikimedia Commons + - thumburl: URL to a thumbnail version (null for non-image or processing files) + """ + if not base_url or not isinstance(base_url, str): + return { + 'success': False, + 'data': None, + 'error': 'Base URL is required' + } + + base_url = base_url.strip() + + # Remove common suffixes that users might include + common_suffixes = ['/home', '/enroll', '/users.json', '/course.json', '/articles.json', '/uploads.json', '/students', '/articles', '/uploads', '/timeline'] + for suffix in common_suffixes: + if base_url.endswith(suffix): + base_url = base_url[:-len(suffix)] + break + + base_url = base_url.rstrip('/') + + # Parse URL to get school and course_slug + parsed = parse_outreach_url(base_url) + if not parsed['valid']: + return { + 'success': False, + 'data': None, + 'error': 'Invalid Outreach Dashboard URL format' + } + + # Build API URL using helper function + api_url = _build_api_url(parsed['school'], parsed['course_slug'], 'uploads.json') + + # Make API request using base handler + result = _make_api_request(api_url) + + if not result['success']: + return result + + # Extract uploads data from response + # Response structure: {'course': {'uploads': [...]}} + data = result['data'] + if 'course' in data and 'uploads' in data['course']: + uploads = data['course']['uploads'] + # Ensure it's a list (API may return empty array) + if isinstance(uploads, list): + return { + 'success': True, + 'data': uploads, + 'error': None + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: "uploads" is not an array' + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format: missing "course.uploads" key' + } + + +def build_course_api_url(base_url: str) -> Optional[str]: + """ + Build the full API URL from a base URL. + + Args: + base_url: Base URL of the course + + Returns: + Full API URL or None if base_url is invalid + """ + parsed = parse_outreach_url(base_url) + if not parsed['valid']: + return None + + return f"{OUTREACH_DASHBOARD_BASE}/courses/{parsed['school']}/{parsed['course_slug']}/course.json" + diff --git a/docs/FEATURES_DOCUMENTATION.md b/docs/FEATURES_DOCUMENTATION.md new file mode 100644 index 0000000..adcd148 --- /dev/null +++ b/docs/FEATURES_DOCUMENTATION.md @@ -0,0 +1,764 @@ +# WikiContest Platform - Complete Features Documentation + +This document provides a comprehensive overview of all features present in the WikiContest community event management platform. This documentation was generated by scanning the entire codebase, including backend models, routes, frontend components, and configuration files. + +--- + +## Table of Contents + +1. [Authentication & User Management](#authentication--user-management) +2. [User Management & Profiles](#user-management--profiles) +3. [Contest Management](#contest-management) +4. [Submission System](#submission-system) +5. [Review & Scoring System](#review--scoring-system) +6. [Trusted Members System](#trusted-members-system) +7. [Contest Requests System](#contest-requests-system) +8. [Leaderboards & Statistics](#leaderboards--statistics) +9. [MediaWiki Integration](#mediawiki-integration) +10. [Frontend Features](#frontend-features) +11. [Security & Permissions](#security--permissions) +12. [Database & Data Models](#database--data-models) + +--- + +## Authentication & User Management + +### Email/Password Authentication +- **User Registration**: Users can register with username, email, and password + - Username validation: 3-20 characters, alphanumeric and underscores only + - Email validation: Standard email format validation + - Password requirements: Minimum 6 characters + - Automatic password hashing using bcrypt + - Duplicate username/email prevention + +- **User Login**: Traditional email/password authentication + - JWT token generation upon successful login + - HTTP-only cookie storage for security + - CSRF protection enabled + - Session management + +- **User Logout**: Secure logout functionality + - Clears JWT tokens + - Clears OAuth session data + - Removes authentication cookies + +### Wikimedia OAuth 1.0a Authentication +- **OAuth Login Flow**: Complete OAuth 1.0a implementation + - Initiates OAuth flow with Wikimedia + - Request token generation and storage + - User authorization redirect to Wikimedia + - Access token exchange + - User identity retrieval from Wikimedia API + - Automatic user creation for new OAuth users + - OAuth token storage for MediaWiki API access + +- **OAuth Configuration**: Flexible OAuth setup + - Configurable callback URLs + - Support for out-of-band (OOB) verification + - Session and cache-based token storage + - Cross-port cookie support for development + +### User Roles & Permissions +- **Role Hierarchy**: + - **User**: Default role, can submit articles and participate + - **Admin**: Can manage contests, review submissions, view all data + - **Superadmin**: Highest privilege level, can manage trusted members and contest requests + +- **Role-Based Access Control**: + - Middleware-based permission checks + - Contextual permissions (contest creator, jury member, organizer) + - Automatic permission inheritance for superadmins + +--- + +## User Management & Profiles + +### User Profiles +- **Profile Viewing**: Users can view their own profile + - Username, email, role display + - Total score display + - Trusted member status + - Account creation date + +- **Profile Editing**: Users can update their profile + - Username modification (with uniqueness validation) + - Email modification (with format and uniqueness validation) + - Real-time validation feedback + +### User Dashboard +- **Statistics Overview**: + - Total accumulated score across all contests + - Number of contests created + - Number of contests where user is a jury member + - Contest-wise score breakdown + +- **Recent Submissions**: + - List of all submissions grouped by contest + - Submission status (pending, accepted, rejected) + - Quick access to submission details + - Review feedback viewing + +- **Created Contests**: + - List of all contests created by the user + - Contest status display + - Submission count per contest + - Quick navigation to contest details + +- **Jury Memberships**: + - List of contests where user serves as jury member + - Quick access to jury dashboard + +### User Search & Lookup +- **User Search**: Autocomplete functionality for finding users + - Search by username (minimum 2 characters) + - Configurable result limit (default: 10) + - Case-insensitive search + +- **User Lookup**: Get username by user ID + - Minimal endpoint for non-sensitive user information + - Used for displaying usernames in various contexts + +--- + +## Contest Management + +### Contest Creation +- **Contest Creation (Trusted Members/Superadmins)**: + - Full contest configuration form + - Contest name and project name + - Description and rules (JSON format) + - Start and end dates + - Jury members assignment (comma-separated usernames) + - Organizers assignment (comma-separated usernames) + - Scoring system configuration (simple or multi-parameter) + - Article requirements (byte count, reference count) + - Category requirements (JSON array of category URLs) + - Template link for automatic template enforcement + - Submission type restrictions (new articles, expansions, or both) + +- **Contest Request System** (for non-privileged users): + - Users can submit contest creation requests + - Superadmins review and approve/reject requests + - Request status tracking (pending, approved, rejected) + - Rejection reason tracking + +### Contest Configuration +- **Basic Information**: + - Contest name (unique identifier) + - Project name (e.g., Wikimedia, Wikipedia) + - Description (rich text support) + - Start and end dates + - Creation timestamp + +- **Scoring Systems**: + - **Simple Scoring**: Fixed points for accepted/rejected submissions + - Configurable accepted submission points + - Configurable rejected submission points + - **Multi-Parameter Scoring**: Weighted scoring system + - Customizable parameters (e.g., Quality, Sources, Neutrality, Formatting) + - Individual parameter weights (must sum to 100%) + - Score range configuration (min/max) + - Parameter descriptions + - Automatic weighted score calculation + +- **Article Requirements**: + - Minimum byte count requirement + - Minimum reference count requirement (footnotes and external links) + - Category requirements (articles must belong to at least one specified category) + - Submission type restrictions: + - New articles only + - Expansions only + - Both allowed + +- **People Management**: + - **Jury Members**: Users who can review submissions + - Comma-separated username list + - Jury members can review all submissions in their assigned contests + - **Organizers**: Users who can manage the contest + - Comma-separated username list + - Creator is automatically included as organizer + - Organizers can edit contest, view submissions, manage settings + - Organizer management (add/remove) with validation + +- **Template & Category Enforcement**: + - Template link (URL to Wiki template page) + - Automatic template attachment to submitted articles + - Category URLs (JSON array) + - Automatic category attachment to submitted articles + - Error tracking for failed template/category attachments + +### Contest Status & Lifecycle +- **Status Tracking**: + - **Current**: Contest is currently active (within date range) + - **Upcoming**: Contest start date is in the future + - **Past**: Contest end date has passed + - **Unknown**: Contest lacks proper date configuration + +- **Contest Filtering**: + - Filter by status (current, upcoming, past) + - Sort by creation date + - Search functionality + +### Contest Viewing & Details +- **Contest Listing**: Categorized display of all contests + - Current contests tab + - Upcoming contests tab + - Past contests tab + - Contest cards with metadata + - Organizer avatars display + - Submission count badges + - Status badges + +- **Contest Details Page**: + - Full contest information display + - Rules and requirements + - Jury members list + - Organizers list + - Submission statistics + - Leaderboard access + - Submission form (for active contests) + +### Contest Editing & Management +- **Contest Updates** (Creator/Organizer/Admin only): + - Edit contest name, description, dates + - Modify jury members and organizers + - Update scoring parameters (with restrictions) + - Change article requirements + - Update template and category links + - Scoring system changes (restricted if submissions reviewed) + +- **Contest Deletion** (Creator/Admin only): + - Delete contest with all associated submissions + - Confirmation required + +--- + +## Submission System + +### Article Submission +- **Submission Process**: + - User provides article URL (Wikipedia article link) + - Automatic article metadata fetching from MediaWiki API + - Article validation against contest requirements + - Automatic template attachment (if configured) + - Automatic category attachment (if configured) + - Submission creation with pending status + +- **Article Metadata Fetching**: + - Article title extraction from URL + - Article size (byte count) from latest revision + - Article author (from latest revision) + - Article creation date (from oldest revision) + - MediaWiki page ID + - Article size at contest start date + - Expansion bytes calculation (current size - size at submission) + +- **Submission Validation**: + - **Byte Count Validation**: Ensures article meets minimum byte requirement + - **Reference Count Validation**: Ensures article meets minimum reference requirement + - **Category Validation**: Ensures article belongs to required categories + - **Submission Type Validation**: Validates new article vs. expansion + - **Duplicate Prevention**: Prevents same user from submitting same article to same contest twice + +- **Template Enforcement**: + - Automatic template attachment to article during submission + - Template presence verification + - Template attachment tracking (template_added flag) + - Error handling for template attachment failures + +- **Category Enforcement**: + - Automatic category attachment to article during submission + - Category presence verification + - Multiple category support + - Category attachment tracking (categories_added JSON array) + - Error tracking for category attachment failures + +### Submission Management +- **Submission Viewing**: + - Users can view their own submissions + - Jury members can view submissions in their contests + - Contest creators/organizers can view all submissions in their contests + - Admins can view all submissions + +- **Submission Details**: + - Article title and link + - Submission status (pending, accepted, rejected, auto_rejected) + - Score awarded + - Submission timestamp + - Review information (reviewer, review date, comments) + - Article metadata (size, author, creation date) + - Template and category attachment status + - Parameter scores (for multi-parameter scoring) + +- **Submission Filtering**: + - Filter by contest + - Filter by user + - Filter by status + - Filter pending submissions (for jury review) + +### Submission Metadata Refresh +- **Bulk Metadata Refresh**: + - Refresh article metadata for all submissions in a contest + - Updates expansion bytes (current size - submission size) + - Preserves original submission-time metadata + - Available to contest creators/organizers/admins + - Batch processing with success/failure reporting + +--- + +## Review & Scoring System + +### Submission Review Workflow +- **Review Process**: + - Jury members review pending submissions + - Review includes status decision (accepted/rejected) + - Optional review comments/feedback + - Score assignment based on contest scoring system + - Automatic user score update + +- **Review Permissions**: + - Jury members can review submissions in their assigned contests + - Contest creators/organizers can review submissions in their contests + - Admins can review all submissions + - One review per submission (prevents re-reviewing) + +### Simple Scoring System +- **Fixed Point Scoring**: + - Configurable points for accepted submissions + - Configurable points for rejected submissions (usually 0) + - Manual score override option (within configured range) + - Score validation (0 to max_score range) + +### Multi-Parameter Scoring System +- **Weighted Parameter Scoring**: + - Multiple scoring parameters (e.g., Quality, Sources, Neutrality, Formatting) + - Individual parameter scores (0-10 scale) + - Weighted average calculation + - Automatic final score calculation + - Score clamping (min_score to max_score range) + - Parameter score storage for transparency + +- **Parameter Configuration**: + - Custom parameter names + - Individual parameter weights (percentages) + - Parameter descriptions + - Weight validation (must sum to 100%) + - Score range configuration (min/max) + +### Review Feedback +- **Review Comments**: + - Optional text feedback from reviewers + - Stored with review metadata + - Visible to submission owners + - Feedback modal display in frontend + +- **Review Metadata**: + - Reviewer identification + - Review timestamp + - Review status (accepted/rejected) + - Score breakdown (for multi-parameter scoring) + +--- + +## Trusted Members System + +### Trusted Member Status +- **Purpose**: Control who can create contests + - Only trusted members and superadmins can create contests + - Regular users can still submit and participate + - Prevents spam and ensures contest quality + +### Trusted Member Requests +- **Request Process**: + - Users can request trusted member status + - Only available to MediaWiki OAuth users + - Automatic approval for users with ≥300 edits + - Manual review required for users with <300 edits + - Reason required for users below edit threshold + +- **Request Management** (Superadmin only): + - View all pending requests + - View request reasons + - Approve or reject requests + - Automatic approval based on edit count + +### Trusted Member Management +- **Member Management** (Superadmin only): + - View all trusted members + - Manually add users as trusted members + - Remove trusted member status + - Cannot remove superadmin status + +- **Edit Count Integration**: + - Fetches user edit count from MediaWiki API + - Automatic approval threshold: 300 edits + - Edit count display in request interface + +--- + +## Contest Requests System + +### Contest Request Submission +- **Request Creation** (Non-privileged users): + - Users who cannot create contests directly can submit requests + - Full contest configuration in request + - Request status: pending, approved, rejected + - Request tracking and history + +### Contest Request Review +- **Request Review** (Superadmin only): + - View all pending contest requests + - Review full contest configuration + - Approve request (creates contest automatically) + - Reject request (with optional reason) + - Request metadata (requester, review date, reviewer) + +--- + +## Leaderboards & Statistics + +### Contest Leaderboards +- **Leaderboard Display**: + - Ranked list of participants by score + - User statistics per contest: + - Total submissions + - Total score (marks) + - Reviewed submissions count + - Pending submissions count + - Sequential ranking + - Pagination support + +- **Leaderboard Filtering**: + - Filter by status (reviewed, pending, all) + - Filter by minimum marks threshold + - Sort by marks or submission count + - Configurable pagination (page, per_page) + +- **Contest Statistics**: + - Total submissions count + - Total participants count + - Average score per participant + - Submission status breakdown + +### User Statistics +- **Dashboard Statistics**: + - Total score across all contests + - Contest-wise score breakdown + - Submission statistics by status + - Acceptance rate calculation + - Created contests count + - Jury memberships count + +### Submission Statistics +- **Submission Stats Endpoint**: + - Total submissions count + - Accepted submissions count + - Rejected submissions count + - Pending submissions count + - Total score + - Acceptance rate percentage + +--- + +## MediaWiki Integration + +### MediaWiki API Integration +- **Article Information Fetching**: + - Article metadata retrieval + - Revision history access + - Article size (byte count) from revisions + - Article author from latest revision + - Article creation date from oldest revision + - Page ID retrieval + - Reference count calculation (footnotes + external links) + +- **Article Editing** (via OAuth): + - Template attachment to articles + - Category attachment to articles + - Edit tracking and error handling + - OAuth token management for authenticated edits + +### Template & Category Management +- **Template Enforcement**: + - Template link validation + - Template name extraction from URL + - Template presence checking + - Automatic template prepending to article + - Template attachment tracking + +- **Category Enforcement**: + - Category URL validation + - Category name extraction from URLs + - Category presence checking + - Automatic category appending to article + - Multiple category support + - Category attachment tracking + +### Article Validation +- **Byte Count Validation**: + - Fetches current article size from MediaWiki API + - Compares against contest minimum requirement + - Error messages for undersized articles + +- **Reference Count Validation**: + - Counts footnotes ( tags) in article + - Counts external links (URLs) in article + - Compares against contest minimum requirement + - Error messages for insufficient references + +- **Category Validation**: + - Checks if article belongs to required categories + - Validates category presence on article + - Error messages for missing categories + +--- + +## Frontend Features + +### User Interface Components +- **Home Page**: Landing page with login/registration options +- **Contest Listing Page**: Categorized contest display with filtering +- **Contest Details Page**: Full contest information and submission interface +- **Dashboard**: User statistics and submission overview +- **Profile Page**: User profile viewing and editing +- **Jury Dashboard**: Interface for reviewing submissions +- **Trusted Members Page**: Management interface for trusted members (superadmin) +- **Leaderboard Page**: Contest leaderboard display + +### UI Components +- **AlertContainer**: Global alert notification system +- **ArticlePreviewModal**: Preview Wikipedia articles before submission +- **ContestLeaderboard**: Leaderboard display component +- **ContestModal**: Contest details modal +- **CreateContestModal**: Contest creation form +- **JuryDashboard**: Submission review interface +- **JuryFeedbackModal**: Display review feedback to users +- **RequestContestModal**: Contest request submission form +- **RequestTrustedMemberModal**: Trusted member request form +- **ReviewSubmissionModal**: Submission review form for jury +- **SubmitArticleModal**: Article submission form + +### Frontend Features +- **Responsive Design**: Mobile, tablet, and desktop optimization +- **Real-time Updates**: Dynamic content loading +- **Form Validation**: Client-side validation with error messages +- **Loading States**: Visual feedback during async operations +- **Error Handling**: User-friendly error messages +- **Navigation Guards**: Protected routes with authentication checks +- **OAuth Integration**: Seamless Wikimedia OAuth flow +- **Modal Dialogs**: Context-aware forms and confirmations + +### State Management +- **Vue 3 Composition API**: Modern reactive state management +- **Store Pattern**: Centralized state for user, contests, submissions +- **Authentication State**: Persistent login state +- **Contest Filtering**: Client-side contest categorization + +--- + +## Security & Permissions + +### Authentication Security +- **JWT Tokens**: Secure token-based authentication +- **HTTP-Only Cookies**: Secure token storage (prevents XSS) +- **CSRF Protection**: Cross-site request forgery prevention +- **Password Hashing**: bcrypt password hashing with salt +- **Session Management**: Secure session handling + +### Authorization & Access Control +- **Role-Based Access Control (RBAC)**: + - User role hierarchy (user, admin, superadmin) + - Role-based permission checks + - Contextual permissions (contest creator, jury, organizer) + +- **Permission Checks**: + - Contest creation: Trusted members and superadmins only + - Contest editing: Creator, organizers, and admins + - Submission review: Jury members, organizers, and admins + - Submission viewing: Owner, jury, organizers, and admins + - Trusted member management: Superadmin only + - Contest request review: Superadmin only + +- **Middleware Protection**: + - Authentication middleware (require_auth) + - Role-based middleware (require_role) + - Submission permission middleware (require_submission_permission) + - Automatic permission validation + +### Data Validation +- **Input Validation**: Server-side validation for all inputs +- **SQL Injection Prevention**: SQLAlchemy ORM with parameterized queries +- **XSS Prevention**: Output escaping and sanitization +- **CSRF Tokens**: Token-based CSRF protection + +--- + +## Database & Data Models + +### User Model +- **Core Fields**: + - id (Primary Key) + - username (Unique, indexed) + - email (Unique, indexed) + - password (Hashed) + - role (user, admin, superadmin) + - score (Total accumulated score) + - created_at (Timestamp) + +- **OAuth Fields**: + - oauth_token (MediaWiki OAuth token) + - oauth_token_secret (MediaWiki OAuth secret) + +- **Trusted Member Fields**: + - is_trusted_member (Boolean) + - trusted_member_request (Boolean) + - trusted_member_request_reason (Text) + +- **Relationships**: + - created_contests (One-to-many) + - submissions (One-to-many) + - reviewed_submissions (One-to-many) + - contest_requests (One-to-many) + +### Contest Model +- **Core Fields**: + - id (Primary Key) + - name (String) + - project_name (String) + - created_by (Foreign Key to User.username) + - description (Text) + - start_date (Date) + - end_date (Date) + - created_at (Timestamp) + +- **Scoring Fields**: + - marks_setting_accepted (Integer) + - marks_setting_rejected (Integer) + - scoring_parameters (JSON Text) + - allowed_submission_type (String: new, expansion, both) + +- **Requirements Fields**: + - min_byte_count (Integer) + - min_reference_count (Integer) + - categories (JSON Text) + - template_link (Text) + +- **People Fields**: + - jury_members (Text, comma-separated) + - organizers (Text, comma-separated) + +- **Relationships**: + - submissions (One-to-many) + - creator (Many-to-one to User) + +### Submission Model +- **Core Fields**: + - id (Primary Key) + - user_id (Foreign Key to User) + - contest_id (Foreign Key to Contest) + - article_title (String) + - article_link (String) + - status (String: pending, accepted, rejected, auto_rejected) + - score (Integer) + - submitted_at (Timestamp) + +- **Article Metadata Fields**: + - article_author (String) + - article_created_at (DateTime) + - article_word_count (Integer) + - article_page_id (String) + - article_size_at_start (Integer) + - article_expansion_bytes (Integer) + +- **Enforcement Fields**: + - template_added (Boolean) + - categories_added (JSON Text) + - category_error (Text) + +- **Review Fields**: + - reviewed_by (Foreign Key to User) + - reviewed_at (DateTime) + - review_comment (Text) + - parameter_scores (JSON Text) + +- **Relationships**: + - submitter (Many-to-one to User) + - reviewer (Many-to-one to User) + - contest (Many-to-one to Contest) + +- **Constraints**: + - Unique constraint: (user_id, contest_id, article_link) + +### ContestRequest Model +- **Core Fields**: + - id (Primary Key) + - user_id (Foreign Key to User) + - name, project_name, description + - start_date, end_date + - rules (JSON Text) + - jury_members, organizers (Text) + - min_byte_count, min_reference_count + - categories (JSON Text) + - template_link (Text) + - scoring_parameters (JSON Text) + - allowed_submission_type + - marks_setting_accepted, marks_setting_rejected + - status (String: pending, approved, rejected) + - reviewed_by (Foreign Key to User) + - reviewed_at (DateTime) + - rejection_reason (Text) + - created_at (Timestamp) + +- **Relationships**: + - requester (Many-to-one to User) + - reviewer (Many-to-one to User) + +### Database Features +- **Migrations**: Alembic for version-controlled schema management +- **Relationships**: SQLAlchemy ORM relationships with lazy loading +- **Indexes**: Optimized queries with indexed fields +- **Constraints**: Unique constraints and foreign keys +- **Transactions**: ACID-compliant database transactions + +--- + +## Additional Features + +### Utility Scripts +- **Database Initialization**: Scripts for database setup +- **Metadata Backfilling**: Scripts to backfill article metadata +- **Article Metadata Fetching**: Standalone scripts for testing +- **Migration Management**: Alembic migration helpers + +### Deployment Features +- **Toolforge Support**: Wikimedia Toolforge deployment configuration +- **Production Build**: Optimized frontend build process +- **Environment Configuration**: Flexible environment variable configuration +- **Logging**: Application logging system + +### API Features +- **RESTful API**: Clean REST API design +- **JSON Responses**: Consistent JSON response format +- **Error Handling**: Comprehensive error handling +- **Health Checks**: API health check endpoint +- **Cookie Management**: Secure cookie handling + +--- + +## Summary + +The WikiContest platform is a comprehensive event management system specifically designed for Wikipedia article editing contests. It provides: + +- **Complete Authentication System**: Email/password and Wikimedia OAuth +- **Flexible Contest Management**: Creation, configuration, and lifecycle management +- **Robust Submission System**: Article submission with automatic validation and enforcement +- **Advanced Scoring Systems**: Simple and multi-parameter scoring options +- **Comprehensive Review Workflow**: Jury-based review with feedback +- **Trusted Member System**: Controlled contest creation with edit count integration +- **Rich Statistics & Leaderboards**: Detailed analytics and rankings +- **MediaWiki Integration**: Deep integration with Wikipedia infrastructure +- **Modern Frontend**: Responsive Vue.js 3 application with excellent UX +- **Security & Permissions**: Role-based access control with comprehensive security + +The platform is production-ready and designed for deployment on Wikimedia Toolforge, making it ideal for Wikipedia community events and edit-a-thons. + diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md new file mode 100644 index 0000000..6099a6c --- /dev/null +++ b/docs/PROJECT_DESCRIPTION.md @@ -0,0 +1,44 @@ +# WikiContest Project Description + +WikiContest is a web platform for organizing and managing Wikipedia editing contests (edit-a-thons), enabling users to create contests, submit article edits, track submissions, and compete in leaderboards with proper authentication and role-based access control. + +## Overview + +The platform facilitates collaborative Wikipedia editing competitions by providing contest management tools, submission tracking, automated template enforcement, and jury review workflows. It integrates with Wikimedia OAuth for secure authentication and supports deployment on Wikimedia Toolforge. + +## Key Features + +- **Contest Management**: Create and manage editing contests with configurable parameters (dates, categories, byte count ranges, template requirements) +- **Article Submission**: Submit Wikipedia articles to contests with automatic template attachment and validation +- **Jury Review System**: Review and score submissions with acceptance/rejection workflows +- **Leaderboards**: Real-time ranking of participants based on submission quality and quantity +- **User Authentication**: Secure login via email/password or Wikimedia OAuth 1.0a +- **Role-Based Access**: Differentiated permissions for users, contest creators, jury members, and administrators +- **Template Enforcement**: Automatic validation and attachment of contest templates to submitted articles + +## Technology Stack + +- **Backend**: Python Flask with SQLAlchemy ORM, Alembic for database migrations +- **Frontend**: Vue.js 3 with Vite, Vue Router, and Bootstrap 5 +- **Database**: MySQL (production) or SQLite (development) +- **Authentication**: JWT tokens in HTTP-only cookies with CSRF protection, plus Wikimedia OAuth 1.0a +- **Deployment**: Local development environment and Wikimedia Toolforge production deployment + +## Project Links + +- **Repository**: [Link to repository if available] +- **Documentation**: See `docs/PROJECT_DOCUMENTATION.md` for detailed architecture and API documentation +- **Development Guide**: See `docs/DEVELOPMENT_GUIDE.md` for setup and development instructions +- **Toolforge Deployment**: See `docs/TOOLFORGE_DEPLOYMENT.md` for production deployment guide + +## Use Cases + +- Organizing Wikipedia edit-a-thons with structured submission and review processes +- Running article improvement contests with automated template management +- Tracking and scoring collaborative editing efforts across multiple participants +- Managing jury workflows for evaluating contest submissions + +## Related Projects + +This project integrates with Wikimedia infrastructure and follows Wikimedia development practices for OAuth authentication and Toolforge deployment. + diff --git a/frontend/src/components/CreateContestModal.vue b/frontend/src/components/CreateContestModal.vue index cbd242c..447a480 100644 --- a/frontend/src/components/CreateContestModal.vue +++ b/frontend/src/components/CreateContestModal.vue @@ -371,6 +371,21 @@ + +
+ + + + + Link this contest to an Outreach Dashboard course. If provided, course statistics and information will be displayed in a dedicated tab. + Format: https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug} + +
+ @@ -507,6 +522,7 @@ export default { min_byte_count: 0, categories: [''], template_link: '', + outreach_dashboard_url: '', min_reference_count: 0 }) @@ -788,6 +804,24 @@ export default { } } + // Validate Outreach Dashboard URL if provided + if (formData.outreach_dashboard_url && formData.outreach_dashboard_url.trim()) { + const outreachUrl = formData.outreach_dashboard_url.trim() + // Basic URL format validation + if (!outreachUrl.startsWith('http://') && !outreachUrl.startsWith('https://')) { + showAlert('Outreach Dashboard URL must be a valid HTTP/HTTPS URL', 'warning') + return + } + // Check if it's an Outreach Dashboard URL + if (!outreachUrl.includes('outreachdashboard.wmflabs.org')) { + showAlert( + 'Outreach Dashboard URL must point to outreachdashboard.wmflabs.org', + 'warning' + ) + return + } + } + // Scoring-specific validation if (enableMultiParameterScoring.value) { if (totalWeight.value !== 100) { @@ -855,6 +889,13 @@ export default { templateLinkValue = trimmed.length > 0 ? trimmed : null } + // Prepare Outreach Dashboard URL: trim if provided, otherwise set to null + let outreachUrlValue = null + if (formData.outreach_dashboard_url && typeof formData.outreach_dashboard_url === 'string') { + const trimmed = formData.outreach_dashboard_url.trim() + outreachUrlValue = trimmed.length > 0 ? trimmed : null + } + // Construct contest data payload with all form values const contestData = { ...formData, @@ -869,12 +910,17 @@ export default { categories: formData.categories.filter(cat => cat && cat.trim()).map(cat => cat.trim()), // Template link (optional): trim or set to null if empty template_link: templateLinkValue, + // Outreach Dashboard URL (optional): trim or set to null if empty + outreach_dashboard_url: outreachUrlValue, scoring_parameters: scoringParametersPayload, min_reference_count: formData.min_reference_count || 0 } // Submit contest creation request + console.log('[CREATE CONTEST] Submitting contest data:', contestData) const result = await store.createContest(contestData) + console.log('[CREATE CONTEST] Result:', result) + if (result.success) { showAlert('Contest created successfully!', 'success') emit('created') @@ -892,10 +938,12 @@ export default { // Reset form resetForm() } else { + console.error('[CREATE CONTEST] Error:', result.error) showAlert(result.error || 'Failed to create contest', 'danger') } } catch (error) { - showAlert('Failed to create contest: ' + error.message, 'danger') + console.error('[CREATE CONTEST] Exception:', error) + showAlert('Failed to create contest: ' + (error.message || 'Unknown error'), 'danger') } finally { // Always reset loading state loading.value = false @@ -915,6 +963,7 @@ export default { formData.min_byte_count = 0 formData.categories = [''] formData.template_link = '' + formData.outreach_dashboard_url = '' formData.min_reference_count = 0 // Reset dates to default (tomorrow and next week) diff --git a/frontend/src/components/OutreachDashboardTab.vue b/frontend/src/components/OutreachDashboardTab.vue new file mode 100644 index 0000000..0a1ab62 --- /dev/null +++ b/frontend/src/components/OutreachDashboardTab.vue @@ -0,0 +1,847 @@ + + + + + + diff --git a/frontend/src/services/outreachDashboard.js b/frontend/src/services/outreachDashboard.js new file mode 100644 index 0000000..d55f041 --- /dev/null +++ b/frontend/src/services/outreachDashboard.js @@ -0,0 +1,317 @@ +/** + * Outreach Dashboard API Service + * + * This module provides functions to interact with Wikimedia's Outreach Dashboard API + * from the frontend. It handles fetching course data and parsing URLs. + */ + +import api from './api' + +/** + * Parse an Outreach Dashboard URL to extract components + * + * @param {string} url - The Outreach Dashboard URL + * @returns {Object} Object with school, course_slug, and valid flag + */ +export function parseOutreachUrl(url) { + if (!url || typeof url !== 'string') { + return { school: null, course_slug: null, valid: false } + } + + url = url.trim() + + try { + const urlObj = new URL(url) + + // Check if it's an Outreach Dashboard URL + if (!urlObj.hostname.includes('outreachdashboard.wmflabs.org')) { + return { school: null, course_slug: null, valid: false } + } + + // Extract path components + // Pattern: /courses/{school}/{course_slug} or /courses/{school}/{course_slug}/course.json + const pathMatch = urlObj.pathname.match(/^\/courses\/([^/]+)\/([^/]+)(?:\/course\.json)?\/?$/) + + if (pathMatch) { + return { + school: pathMatch[1], + course_slug: pathMatch[2], + valid: true + } + } + } catch (e) { + // Invalid URL format + return { school: null, course_slug: null, valid: false } + } + + return { school: null, course_slug: null, valid: false } +} + +/** + * Validate an Outreach Dashboard URL format + * + * @param {string} url - The URL to validate + * @returns {Object} Object with valid flag and optional error message + */ +export function validateOutreachUrl(url) { + if (!url || typeof url !== 'string') { + return { valid: false, error: 'URL is required' } + } + + url = url.trim() + + if (!url) { + return { valid: false, error: 'URL cannot be empty' } + } + + // Check basic URL format + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return { valid: false, error: 'URL must start with http:// or https://' } + } + + // Parse the URL + const parsed = parseOutreachUrl(url) + + if (!parsed.valid) { + return { + valid: false, + error: 'Invalid Outreach Dashboard URL format. Expected: https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug}' + } + } + + return { valid: true, error: null } +} + +/** + * Fetch course data from Outreach Dashboard API + * + * @param {string} baseUrl - Base URL of the course (without /course.json) + * @returns {Promise} Promise resolving to course data or error + */ +export async function fetchCourseData(baseUrl) { + if (!baseUrl || typeof baseUrl !== 'string') { + return { + success: false, + data: null, + error: 'Base URL is required' + } + } + + baseUrl = baseUrl.trim() + + // Ensure baseUrl doesn't end with /course.json + if (baseUrl.endsWith('/course.json')) { + baseUrl = baseUrl.slice(0, -12) + } + baseUrl = baseUrl.replace(/\/$/, '') // Remove trailing slash + + // Parse URL to get school and course_slug + const parsed = parseOutreachUrl(baseUrl) + if (!parsed.valid) { + return { + success: false, + data: null, + error: 'Invalid Outreach Dashboard URL format' + } + } + + // Build API URL + const apiUrl = `https://outreachdashboard.wmflabs.org/courses/${parsed.school}/${parsed.course_slug}/course.json` + + try { + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + + if (response.status === 404) { + return { + success: false, + data: null, + error: 'Course not found. Please verify the URL is correct.' + } + } + + if (!response.ok) { + return { + success: false, + data: null, + error: `API returned status code ${response.status}` + } + } + + const data = await response.json() + + // Extract course data from response + if (data.course) { + return { + success: true, + data: data.course, + error: null + } + } else { + return { + success: false, + data: null, + error: 'Invalid API response format' + } + } + } catch (error) { + if (error.name === 'TypeError' && error.message.includes('fetch')) { + return { + success: false, + data: null, + error: 'Failed to connect to Outreach Dashboard API. Please check your internet connection.' + } + } + return { + success: false, + data: null, + error: error.message || 'Unexpected error occurred' + } + } +} + +/** + * Fetch course users data from backend API endpoint + * + * This function calls the backend endpoint which then fetches data from + * Outreach Dashboard API. This is the preferred method as it goes through + * the backend service layer. + * + * @param {number} contestId - ID of the contest + * @returns {Promise} Promise resolving to users data or error + */ +export async function fetchCourseUsers(contestId) { + if (!contestId || typeof contestId !== 'number') { + return { + success: false, + data: null, + error: 'Contest ID is required' + } + } + + try { + const response = await api.get(`/contest/${contestId}/outreach-users`) + + // Note: api.get() returns response.data directly due to axios interceptor + if (response.success) { + return { + success: true, + data: response.data, + error: null + } + } else { + return { + success: false, + data: null, + error: response.error || 'Failed to fetch course users' + } + } + } catch (error) { + console.error('Error fetching course users:', error) + const errorMessage = error.response?.data?.error || error.message || 'Unknown error occurred' + return { + success: false, + data: null, + error: errorMessage + } + } +} + +/** + * Fetch course articles data from backend API endpoint + * + * This function calls the backend endpoint which then fetches data from + * Outreach Dashboard API. This is the preferred method as it goes through + * the backend service layer. + * + * @param {number} contestId - ID of the contest + * @returns {Promise} Promise resolving to articles data or error + */ +export async function fetchCourseArticles(contestId) { + if (!contestId || typeof contestId !== 'number') { + return { + success: false, + data: null, + error: 'Contest ID is required' + } + } + + try { + const response = await api.get(`/contest/${contestId}/outreach-articles`) + + // Note: api.get() returns response.data directly due to axios interceptor + if (response.success) { + return { + success: true, + data: response.data, + error: null + } + } else { + return { + success: false, + data: null, + error: response.error || 'Failed to fetch course articles' + } + } + } catch (error) { + console.error('Error fetching course articles:', error) + const errorMessage = error.response?.data?.error || error.message || 'Unknown error occurred' + return { + success: false, + data: null, + error: errorMessage + } + } +} + +/** + * Fetch course uploads data from backend API endpoint + * + * This function calls the backend endpoint which then fetches data from + * Outreach Dashboard API. This is the preferred method as it goes through + * the backend service layer. + * + * @param {number} contestId - ID of the contest + * @returns {Promise} Promise resolving to uploads data or error + */ +export async function fetchCourseUploads(contestId) { + if (!contestId || typeof contestId !== 'number') { + return { + success: false, + data: null, + error: 'Contest ID is required' + } + } + + try { + const response = await api.get(`/contest/${contestId}/outreach-uploads`) + + // Note: api.get() returns response.data directly due to axios interceptor + if (response.success) { + return { + success: true, + data: response.data, + error: null + } + } else { + return { + success: false, + data: null, + error: response.error || 'Failed to fetch course uploads' + } + } + } catch (error) { + console.error('Error fetching course uploads:', error) + const errorMessage = error.response?.data?.error || error.message || 'Unknown error occurred' + return { + success: false, + data: null, + error: errorMessage + } + } +} + diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index a1f9268..025c996 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -219,12 +219,16 @@ export function useStore() { // Create contest const createContest = async (contestData) => { try { + console.log('[STORE] Creating contest with data:', contestData) const response = await api.post('/contest', contestData) + console.log('[STORE] Contest created successfully:', response) // Reload contests after creation await loadContests() return { success: true, data: response } } catch (error) { - return { success: false, error: error.message } + console.error('[STORE] Error creating contest:', error) + const errorMessage = error.response?.data?.error || error.message || 'Unknown error occurred' + return { success: false, error: errorMessage } } } diff --git a/frontend/src/views/ContestView.vue b/frontend/src/views/ContestView.vue index 764e037..0414567 100644 --- a/frontend/src/views/ContestView.vue +++ b/frontend/src/views/ContestView.vue @@ -50,9 +50,25 @@ - -
-
+ + + +
+ +
+
+
@@ -73,10 +89,9 @@
-
-
+
- +
@@ -373,6 +388,338 @@ Submit Article
+
+
+ + +
+ +
+
+ + +
+
+ +
+
+
Contest Details
+
+
+

Project: {{ contest.project_name }}

+

Status: {{ contest.status }}

+

Start Date: {{ formatDate(contest.start_date) }}

+

End Date: {{ formatDate(contest.end_date) }}

+ + Organizers: +
+
+ + {{ organizer }} +
+
+
+
+
+ + +
+
+
+
Scoring System
+
+ +
+ +
+
+ Accepted points: {{ contest.scoring_parameters.max_score }} + Rejected points: {{ contest.scoring_parameters.min_score }} +
+ +
+
+
+ {{ param.name }} + {{ param.weight }}% +
+

{{ param.description }}

+
+
+ +
+ + Each parameter scored 0-10, weighted average calculated +
+
+ + +
+
+
+ Accepted + {{ contest.marks_setting_accepted }} +
+ +
+ Rejected + {{ contest.marks_setting_rejected }} +
+
+
+
+
+
+ + +
+
+
Description
+
+
+

{{ contest.description }}

+
+
+ + +
+
+
Contest Rules
+
+
+
{{ contest.rules.text }}
+
+
+ + +
+
+
Submission Type Allowed
+
+
+

+ + {{ + contest.allowed_submission_type === 'new' + ? 'New Articles Only' + : contest.allowed_submission_type === 'expansion' + ? 'Improved Articles Only' + : 'Both (New Articles + Improved Articles)' + }} + +

+ +

+ + • New Articles = Completely new Wikipedia article created during the contest.
+ • Improved Articles = An existing article improved or expanded with substantial content. +
+

+
+
+ + +
+
+
Required Categories
+
+
+

+ Articles must belong to the following MediaWiki categories: +

+ + + + Submitted articles must be categorized under at least one of these categories. + +
+
+ + +
+
+
Minimum Reference Count
+
+
+

+ {{ contest.min_reference_count }} References required +

+ + + Submitted articles must have at least {{ contest.min_reference_count }} external references. + +
+
+ + +
+
+
Jury Members
+
+
+
+
+ + {{ jury }} +
+
+
+
+ + +
+
+
+
Submissions
+ + + +
+
+
+
+ No submissions yet for this contest. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Article TitleArticle AuthorSubmitted ByStatusScoreSubmitted AtActions
+ + {{ submission.article_title }} + + + +
+ Total bytes: + {{ formatByteCountWithExact((submission.article_word_count || 0) + + (submission.article_expansion_bytes || 0)) }} +
+ +
+ Original bytes: + {{ formatByteCountWithExact(submission.article_word_count) }} +
+ +
+ + Expansion bytes: + + {{ submission.article_expansion_bytes >= 0 ? '+' : '-' }}{{ + formatByteCountWithExact(Math.abs(submission.article_expansion_bytes)) + }} + + + {{ formatByteCountWithExact(0) }} + +
+
+
+ {{ submission.article_author }} +
+
Unknown
+
+ {{ formatDateShort(submission.article_created_at) }} +
+ +
+
+ {{ submission.latest_revision_author }} + Latest +
+
+ + {{ formatDateShort(submission.latest_revision_timestamp) }} +
+
+
{{ submission.username || 'Unknown' }} + + {{ submission.status }} + +
+ Reviewed +
+
{{ submission.score || 0 }}{{ formatDate(submission.submitted_at) }} + + +
+
+
+
+ + +
+ +
+ + User not loaded! + +
+ + + +
+
@@ -900,6 +1247,21 @@ If set, this template will be automatically added to submitted articles that don't already have it. + + +
+ + + + + Link this contest to an Outreach Dashboard course. If provided, course statistics and information will be displayed in a dedicated tab. + Format: https://outreachdashboard.wmflabs.org/courses/{school}/{course_slug} + +
@@ -929,12 +1291,14 @@ import api from '../services/api' import { showAlert } from '../utils/alerts' import SubmitArticleModal from '../components/SubmitArticleModal.vue' import ArticlePreviewModal from '../components/ArticlePreviewModal.vue' +import OutreachDashboardTab from '../components/OutreachDashboardTab.vue' export default { name: 'ContestView', components: { SubmitArticleModal, - ArticlePreviewModal + ArticlePreviewModal, + OutreachDashboardTab }, setup() { @@ -1539,6 +1903,7 @@ export default { min_reference_count: 0, categories: [''], template_link: '', + outreach_dashboard_url: '', scoring_mode: 'simple', scoring_parameters: { max_score: 10, @@ -1712,6 +2077,7 @@ export default { } editForm.template_link = contest.value.template_link || '' + editForm.outreach_dashboard_url = contest.value.outreach_dashboard_url || '' // Count reviewed submissions (accepted or rejected) const reviewedSubmissions = submissions.value.filter( @@ -1843,6 +2209,12 @@ export default { templateLinkValue = trimmed.length > 0 ? trimmed : null } + let outreachUrlValue = null + if (editForm.outreach_dashboard_url && typeof editForm.outreach_dashboard_url === 'string') { + const trimmed = editForm.outreach_dashboard_url.trim() + outreachUrlValue = trimmed.length > 0 ? trimmed : null + } + const payload = { name: editForm.name || '', project_name: editForm.project_name || '', @@ -1859,6 +2231,7 @@ export default { min_reference_count: Number(editForm.min_reference_count) || 0, categories: validCategories.map(cat => cat.trim()), template_link: templateLinkValue, + outreach_dashboard_url: outreachUrlValue, marks_setting_accepted: Number(editForm.marks_setting_accepted), marks_setting_rejected: Number(editForm.marks_setting_rejected), scoring_parameters: scoringParametersPayload