From 4592bd508a9ffc8c3ed146b13c9ae61cda47370b Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:16 +0530 Subject: [PATCH 01/25] feat(database): add outreach_dashboard_url column to contests table - Add migration to add outreach_dashboard_url column to contests table - Column is nullable to support optional Outreach Dashboard integration - Migration includes upgrade and downgrade functions --- ...3_add_outreach_dashboard_url_column_to_.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 backend/alembic/versions/d55c876a1323_add_outreach_dashboard_url_column_to_.py 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') + From f110b0703091a679dc34e0663556a2f4b6f3e8da Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:18 +0530 Subject: [PATCH 02/25] feat(models): add outreach_dashboard_url field to Contest model - Add outreach_dashboard_url field to Contest model - Include field in to_dict() serialization - Update __init__ to accept outreach_dashboard_url parameter --- backend/app/models/contest.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 ), From 2ea47bc4ea0fbed2332f3af76253b24525850bb3 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:19 +0530 Subject: [PATCH 03/25] feat(backend): add Outreach Dashboard API service - Create outreach_dashboard service module with URL parsing and validation - Implement parse_outreach_url() to extract school and course_slug from URL - Implement validate_outreach_url() to validate URL format - Implement fetch_course_data() to fetch course data from Outreach Dashboard API - Add error handling for network issues and API errors - Export service functions in services __init__.py --- backend/app/services/__init__.py | 4 + backend/app/services/outreach_dashboard.py | 226 +++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/outreach_dashboard.py diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..98ae5ab --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,4 @@ +""" +Services package for WikiContest Application +""" + diff --git a/backend/app/services/outreach_dashboard.py b/backend/app/services/outreach_dashboard.py new file mode 100644 index 0000000..f045a4b --- /dev/null +++ b/backend/app/services/outreach_dashboard.py @@ -0,0 +1,226 @@ +""" +Outreach Dashboard API Service + +This module provides functions to interact with Wikimedia's Outreach Dashboard API. +It handles URL parsing, validation, and fetching course data. +""" + +import re +from typing import Dict, Optional, Any +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 + + 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('/') + + # Pattern: /courses/{school}/{course_slug} or /courses/{school}/{course_slug}/course.json + pattern = r'^courses/([^/]+)/([^/]+)(?:/course\.json)?/?$' + match = re.match(pattern, path) + + if match: + school = match.group(1) + course_slug = match.group(2) + return { + 'school': school, + 'course_slug': course_slug, + 'valid': True + } + + return {'school': None, 'course_slug': None, 'valid': False} + + +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 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() + + # Ensure base_url doesn't end with /course.json + if base_url.endswith('/course.json'): + base_url = base_url[:-12] + 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 + api_url = f"{OUTREACH_DASHBOARD_BASE}/courses/{parsed['school']}/{parsed['course_slug']}/course.json" + + 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': 'Course 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)}' + } + + # Extract course data from response + if 'course' in data: + return { + 'success': True, + 'data': data['course'], + 'error': None + } + else: + return { + 'success': False, + 'data': None, + 'error': 'Invalid API response format' + } + + 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 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" + From c290a36d1f0b8cf7cbb7179119adf6f480fdceb8 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:21 +0530 Subject: [PATCH 04/25] feat(backend): integrate Outreach Dashboard URL in contest routes - Add outreach_dashboard_url validation in create_contest endpoint - Add outreach_dashboard_url validation in update_contest endpoint - Create new endpoint GET /contest//outreach-data to fetch course data - Pass outreach_dashboard_url to Contest constructor - Handle cases where URL is missing or API call fails --- backend/app/routes/contest_routes.py | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index 6001154..ca2c548 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -32,6 +32,10 @@ get_article_reference_count, MEDIAWIKI_API_TIMEOUT, ) +from app.services.outreach_dashboard import ( + validate_outreach_url, + fetch_course_data, +) # ------------------------------------------------------------------------ @@ -151,6 +155,44 @@ def get_contest_by_id(contest_id): return jsonify(contest.to_dict()), 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("/name/", methods=["GET"]) @require_auth @handle_errors @@ -610,6 +652,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 +695,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 +993,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: From 1991cb5d95788dccd497b3c6ca284fc75282980f Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:23 +0530 Subject: [PATCH 05/25] feat(frontend): add Outreach Dashboard service - Create outreachDashboard service for API calls - Implement fetchCourseData() to fetch course data from backend - Use existing api axios instance for consistency - Include error handling --- frontend/src/services/outreachDashboard.js | 174 +++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 frontend/src/services/outreachDashboard.js diff --git a/frontend/src/services/outreachDashboard.js b/frontend/src/services/outreachDashboard.js new file mode 100644 index 0000000..9ea7781 --- /dev/null +++ b/frontend/src/services/outreachDashboard.js @@ -0,0 +1,174 @@ +/** + * 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. + */ + +/** + * 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' + } + } +} + From a58d1e92c1d8462e536a2b7ade1b029fcf78b59c Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:25 +0530 Subject: [PATCH 06/25] feat(frontend): add Outreach Dashboard URL field to contest creation form - Add outreach_dashboard_url input field to contest creation form - Add URL validation for Outreach Dashboard URLs - Include field in form submission payload - Add helper text explaining URL format - Reset field on form reset --- .../src/components/CreateContestModal.vue | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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) From 58c0febcb3e35d7b17222c81d0d979b29d81e410 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:28 +0530 Subject: [PATCH 07/25] feat(frontend): create OutreachDashboardTab component - Create new component to display Outreach Dashboard course information - Fetch course data using outreachDashboard service - Display course details: title, description, school, type, dates - Show course statistics: student count, edit count, article count, etc. - Display status indicators: published, ended, closed, wiki_edits_enabled - Include refresh button to reload data - Handle loading and error states gracefully --- .../src/components/OutreachDashboardTab.vue | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 frontend/src/components/OutreachDashboardTab.vue diff --git a/frontend/src/components/OutreachDashboardTab.vue b/frontend/src/components/OutreachDashboardTab.vue new file mode 100644 index 0000000..6838815 --- /dev/null +++ b/frontend/src/components/OutreachDashboardTab.vue @@ -0,0 +1,333 @@ + + + + + + From 607050a82082f5665e0cb76990c43b86a13d1ef6 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:30 +0530 Subject: [PATCH 08/25] feat(frontend): improve error handling in createContest store method - Add console logging for debugging contest creation - Improve error message extraction from API responses - Add better error logging for troubleshooting --- frontend/src/store/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 } } } From 94674f1bd33dfc8fa7af7b06b646bfa697ade8e0 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:24:33 +0530 Subject: [PATCH 09/25] feat(frontend): integrate Outreach Dashboard tab in contest view - Add tab navigation when contest has outreach_dashboard_url - Add Overview and Outreach Dashboard tabs - Conditionally render OutreachDashboardTab component - Add outreach_dashboard_url field to contest edit form - Include field in contest update payload - Fix template structure for proper tab display - Show Overview content directly when no Outreach Dashboard URL --- frontend/src/views/ContestView.vue | 387 ++++++++++++++++++++++++++++- 1 file changed, 380 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/ContestView.vue b/frontend/src/views/ContestView.vue index 764e037..93d6ae7 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 From 1a9d5253428c0f83545fd67f8eb3b26eaa66ada4 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 18:29:51 +0530 Subject: [PATCH 10/25] fix(backend): handle Outreach Dashboard URLs with trailing paths Refactor parse_outreach_url to use path splitting instead of regex pattern matching. This allows the function to automatically ignore additional path segments like /home, /enroll, /course.json, etc. that users might include when copying URLs from the Outreach Dashboard website. - Replace regex pattern matching with path splitting approach - Extract only first 3 path segments (courses, school, course_slug) - Ignore any additional path segments automatically - Update documentation to reflect support for URLs with trailing paths --- backend/app/services/outreach_dashboard.py | 51 +++++++++++++++------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/backend/app/services/outreach_dashboard.py b/backend/app/services/outreach_dashboard.py index f045a4b..97a6a04 100644 --- a/backend/app/services/outreach_dashboard.py +++ b/backend/app/services/outreach_dashboard.py @@ -26,6 +26,9 @@ def parse_outreach_url(url: str) -> Dict[str, Optional[str]]: - 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 @@ -54,20 +57,33 @@ def parse_outreach_url(url: str) -> Dict[str, Optional[str]]: # Extract path components path = parsed.path.strip('/') - # Pattern: /courses/{school}/{course_slug} or /courses/{school}/{course_slug}/course.json - pattern = r'^courses/([^/]+)/([^/]+)(?:/course\.json)?/?$' - match = re.match(pattern, path) + # 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('/') - if match: - school = match.group(1) - course_slug = match.group(2) - return { - 'school': school, - 'course_slug': course_slug, - 'valid': True - } + # 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} - 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]: @@ -128,9 +144,14 @@ def fetch_course_data(base_url: str) -> Dict[str, Any]: base_url = base_url.strip() - # Ensure base_url doesn't end with /course.json - if base_url.endswith('/course.json'): - base_url = base_url[:-12] + # 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 From e90e8287ece5b01e03e98dcacf286d6f96bbf87d Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 19:04:24 +0530 Subject: [PATCH 11/25] refactor(backend): refactor Outreach Dashboard service for extensibility - Add helper functions _build_api_url() and _make_api_request() for reusable API interaction - Refactor fetch_course_data() to use new helper functions - Add fetch_course_users() function to fetch course users data - Update service exports in __init__.py - Improve error handling and code organization for future API endpoints --- backend/app/services/__init__.py | 15 ++ backend/app/services/outreach_dashboard.py | 236 ++++++++++++++++----- 2 files changed, 200 insertions(+), 51 deletions(-) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 98ae5ab..77943a1 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -2,3 +2,18 @@ Services package for WikiContest Application """ +from .outreach_dashboard import ( + parse_outreach_url, + validate_outreach_url, + fetch_course_data, + fetch_course_users, + build_course_api_url +) + +__all__ = [ + 'parse_outreach_url', + 'validate_outreach_url', + 'fetch_course_data', + 'fetch_course_users', + 'build_course_api_url' +] diff --git a/backend/app/services/outreach_dashboard.py b/backend/app/services/outreach_dashboard.py index 97a6a04..00eda8b 100644 --- a/backend/app/services/outreach_dashboard.py +++ b/backend/app/services/outreach_dashboard.py @@ -2,11 +2,12 @@ Outreach Dashboard API Service This module provides functions to interact with Wikimedia's Outreach Dashboard API. -It handles URL parsing, validation, and fetching course data. +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 +from typing import Dict, Optional, Any, List from urllib.parse import urlparse, urljoin import requests @@ -122,6 +123,89 @@ def validate_outreach_url(url: str) -> Dict[str, Any]: 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. @@ -163,69 +247,119 @@ def fetch_course_data(base_url: str) -> Dict[str, Any]: 'error': 'Invalid Outreach Dashboard URL format' } - # Build API URL - api_url = f"{OUTREACH_DASHBOARD_BASE}/courses/{parsed['school']}/{parsed['course_slug']}/course.json" + # Build API URL using helper function + api_url = _build_api_url(parsed['school'], parsed['course_slug'], 'course.json') - 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': 'Course 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)}' - } + # 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) - # Extract course data from response - if 'course' in data: - return { - 'success': True, - 'data': data['course'], - 'error': None - } - else: - return { - 'success': False, - 'data': None, - 'error': 'Invalid API response format' - } + 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) - except requests.exceptions.Timeout: + 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': 'Request timed out. The Outreach Dashboard API may be slow or unavailable.' + 'error': 'Base URL is required' } - except requests.exceptions.ConnectionError: + + 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': 'Failed to connect to Outreach Dashboard API. Please check your internet connection.' + 'error': 'Invalid Outreach Dashboard URL format' } - except Exception as e: - current_app.logger.error(f"Error fetching Outreach Dashboard data: {str(e)}") + + # 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': f'Unexpected error: {str(e)}' + 'error': 'Invalid API response format: missing "course.users" key' } From 04052af30b1feebdc0396b82a200c6f587998c04 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 19:04:32 +0530 Subject: [PATCH 12/25] fix(backend): fix route ordering and add outreach-users endpoint - Move specific outreach routes before general / route - Add GET /contest//outreach-users endpoint for fetching course users - Import fetch_course_users from outreach_dashboard service - Fix Flask route matching issue that caused 'Endpoint not found' errors --- backend/app/routes/contest_routes.py | 75 +++++++++++++++++++++------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index ca2c548..8659895 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -35,6 +35,7 @@ from app.services.outreach_dashboard import ( validate_outreach_url, fetch_course_data, + fetch_course_users, ) @@ -132,43 +133,56 @@ def get_all_contests(): return jsonify({"current": current, "upcoming": upcoming, "past": past}), 200 -@contest_bp.route("/", methods=["GET"]) +@contest_bp.route("//outreach-data", methods=["GET"]) @require_auth @handle_errors -def get_contest_by_id(contest_id): +def get_contest_outreach_data(contest_id): """ - Get a specific contest by 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 contest data + 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 - - return jsonify(contest.to_dict()), 200 + + 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-data", methods=["GET"]) +@contest_bp.route("//outreach-users", methods=["GET"]) @require_auth @handle_errors -def get_contest_outreach_data(contest_id): +def get_outreach_dashboard_users(contest_id): """ - Get Outreach Dashboard course data for a contest - - Requires authentication - users must be logged in to view contest details. + Fetch Outreach Dashboard course users data for a contest. Args: - contest_id: Contest ID + contest_id: ID of the contest Returns: - JSON response with Outreach Dashboard course data or error message + JSON response with Outreach Dashboard course users data or error message """ contest = Contest.query.get(contest_id) @@ -178,8 +192,8 @@ def get_contest_outreach_data(contest_id): 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) + # Fetch course users data from Outreach Dashboard API + result = fetch_course_users(contest.outreach_dashboard_url) if result["success"]: return jsonify({ @@ -193,6 +207,29 @@ def get_contest_outreach_data(contest_id): }), 400 +@contest_bp.route("/", methods=["GET"]) +@require_auth +@handle_errors +def get_contest_by_id(contest_id): + """ + Get a specific contest by ID + + Requires authentication - users must be logged in to view contest details. + + Args: + contest_id: Contest ID + + Returns: + JSON response with contest data + """ + contest = Contest.query.get(contest_id) + + if not contest: + return jsonify({"error": "Contest not found"}), 404 + + return jsonify(contest.to_dict()), 200 + + @contest_bp.route("/name/", methods=["GET"]) @require_auth @handle_errors From d11bab389eecb66f55d6202c43febbd130e8426a Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 19:04:39 +0530 Subject: [PATCH 13/25] feat(frontend): add fetchCourseUsers service function - Add fetchCourseUsers() function to call backend outreach-users endpoint - Import api service for HTTP requests - Add proper error handling and validation for contestId parameter - Return consistent response format matching other service functions --- frontend/src/services/outreachDashboard.js | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/frontend/src/services/outreachDashboard.js b/frontend/src/services/outreachDashboard.js index 9ea7781..d1dcad1 100644 --- a/frontend/src/services/outreachDashboard.js +++ b/frontend/src/services/outreachDashboard.js @@ -5,6 +5,8 @@ * from the frontend. It handles fetching course data and parsing URLs. */ +import api from './api' + /** * Parse an Outreach Dashboard URL to extract components * @@ -172,3 +174,50 @@ export async function fetchCourseData(baseUrl) { } } +/** + * 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 + } + } +} + From 6c066f417695c86ba7ca303c5ea4112117b5aab6 Mon Sep 17 00:00:00 2001 From: Agamya Samuel Date: Fri, 30 Jan 2026 19:04:55 +0530 Subject: [PATCH 14/25] feat(frontend): add users tab to OutreachDashboardTab component - Add nested tab navigation for Course and Users sections - Implement users data fetching with loadUsersData() function - Add users table displaying username, role, enrollment date, statistics - Add loading and error states for users data - Add refresh functionality for users data - Update component to accept contestId prop for API calls --- .../src/components/OutreachDashboardTab.vue | 240 ++++++++++++++++-- 1 file changed, 214 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/OutreachDashboardTab.vue b/frontend/src/components/OutreachDashboardTab.vue index 6838815..bad8467 100644 --- a/frontend/src/components/OutreachDashboardTab.vue +++ b/frontend/src/components/OutreachDashboardTab.vue @@ -1,24 +1,59 @@