From e496c8118a62f6413b0203fa1625f2e5e0cf0b7f Mon Sep 17 00:00:00 2001 From: Gauri Date: Wed, 31 Dec 2025 20:39:24 +0530 Subject: [PATCH] Fix review flow and multi-parameter scoring logic --- .../549b0e507d5d_add_scoring_parameters.py | 45 ++ backend/app/models/contest.py | 123 ++++- backend/app/models/submission.py | 78 ++- backend/app/routes/contest_routes.py | 48 ++ backend/app/routes/submission_routes.py | 365 ++++++++----- .../src/components/ArticlePreviewModal.vue | 32 +- .../src/components/CreateContestModal.vue | 305 +++++++---- .../src/components/ReviewSubmissionModal.vue | 500 ++++++++++++++---- frontend/src/views/ContestView.vue | 480 ++++++++++++++--- frontend/src/views/Profile.vue | 2 +- 10 files changed, 1524 insertions(+), 454 deletions(-) create mode 100644 backend/alembic/versions/549b0e507d5d_add_scoring_parameters.py diff --git a/backend/alembic/versions/549b0e507d5d_add_scoring_parameters.py b/backend/alembic/versions/549b0e507d5d_add_scoring_parameters.py new file mode 100644 index 0000000..3b29914 --- /dev/null +++ b/backend/alembic/versions/549b0e507d5d_add_scoring_parameters.py @@ -0,0 +1,45 @@ +"""add_scoring_parameters + +Revision ID: 549b0e507d5d +Revises: 1fa03cdd51b +Create Date: 2025-12-30 16:16:58 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql # IMPORTANT + +# revision identifiers, used by Alembic. +revision = '549b0e507d5d' +down_revision = '1fa03cdd51b' +branch_labels = None +depends_on = None + + +def upgrade(): + # Contest level scoring configuration + op.add_column( + 'contests', + sa.Column( + 'scoring_parameters', + mysql.JSON(), + nullable=True, + comment='Weights for scoring parameters' + ) + ) + + # Submission level jury scores + op.add_column( + 'submissions', + sa.Column( + 'parameter_scores', + mysql.JSON(), + nullable=True, + comment='Per-parameter jury scores and comments' + ) + ) + + +def downgrade(): + op.drop_column('submissions', 'parameter_scores') + op.drop_column('contests', 'scoring_parameters') diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 21eaf2a..0d091e7 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -56,14 +56,19 @@ class Contest(BaseModel): # Byte count requirement for article submissions # Articles must have byte count at least min_byte_count - min_byte_count = db.Column(db.Integer, nullable=False) # Minimum byte count (required) + min_byte_count = db.Column( + db.Integer, nullable=False + ) # Minimum byte count (required) # MediaWiki category URLs (JSON array) # Required categories that articles must belong to - categories = db.Column(db.Text, nullable=False, default='[]') # JSON array of category URLs + categories = db.Column( + db.Text, nullable=False, default="[]" + ) # JSON array of category URLs # Jury members (comma-separated usernames) jury_members = db.Column(db.Text, nullable=True) + scoring_parameters = db.Column(db.Text, nullable=True) # Timestamp created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) @@ -94,10 +99,11 @@ def __init__(self, name, project_name, created_by, **kwargs): self.marks_setting_accepted = kwargs.get("marks_setting_accepted", 0) self.marks_setting_rejected = kwargs.get("marks_setting_rejected", 0) self.allowed_submission_type = kwargs.get("allowed_submission_type", "both") + self.set_scoring_parameters(kwargs.get("scoring_parameters")) # Byte count requirement (required) # Articles must have byte count at least this value - self.min_byte_count = kwargs.get('min_byte_count', 0) + self.min_byte_count = kwargs.get("min_byte_count", 0) # Handle categories (list of category URLs) self.set_categories(kwargs.get("categories", [])) @@ -199,11 +205,17 @@ def validate_byte_count(self, byte_count): # If byte count is None, we can't validate it # This might happen if MediaWiki API fails to fetch the size if byte_count is None: - return False, 'Article byte count could not be determined. Please ensure the article exists and try again.' + return ( + False, + "Article byte count could not be determined. Please ensure the article exists and try again.", + ) # Check minimum byte count (always required) if byte_count < self.min_byte_count: - return False, f'Article byte count ({byte_count}) is below the minimum required ({self.min_byte_count} bytes)' + return ( + False, + f"Article byte count ({byte_count}) is below the minimum required ({self.min_byte_count} bytes)", + ) # Byte count meets the requirement return True, None @@ -305,6 +317,74 @@ def get_leaderboard(self): for row in leaderboard_query ] + def set_scoring_parameters(self, params): + """ + Set scoring parameters configuration + + Args: + params: Dict or None + { + "enabled": true, + "max_score": 100, + "min_score": 0, + "parameters": [ + {"name": "Quality", "weight": 40, "description": "..."}, + {"name": "Sources", "weight": 30, "description": "..."}, + {"name": "Neutrality", "weight": 20, "description": "..."}, + {"name": "Formatting", "weight": 10, "description": "..."} + ] + } + """ + if params is None: + self.scoring_parameters = None + elif isinstance(params, dict): + # Validate weights sum to 100 + if params.get("enabled") and "parameters" in params: + total_weight = sum(p.get("weight", 0) for p in params["parameters"]) + if total_weight != 100: + raise ValueError( + f"Parameter weights must sum to 100, got {total_weight}" + ) + self.scoring_parameters = json.dumps(params) + else: + self.scoring_parameters = None + + def get_scoring_parameters(self): + """Get scoring parameters configuration""" + if not self.scoring_parameters: + return None + try: + return json.loads(self.scoring_parameters) + except json.JSONDecodeError: + return None + + def is_multi_parameter_scoring_enabled(self): + params = self.get_scoring_parameters() + if not isinstance(params, dict): + return False + return params.get("enabled", False) + + def calculate_weighted_score(self, parameter_scores): + if not self.is_multi_parameter_scoring_enabled(): + return self.marks_setting_accepted + + scoring_config = self.get_scoring_parameters() + max_score = scoring_config.get("max_score", 100) + min_score = scoring_config.get("min_score", 0) + parameters = scoring_config.get("parameters", []) + + weighted_sum = 0.0 + for param in parameters: + param_name = param["name"] + weight = param["weight"] / 100.0 + score = parameter_scores.get(param_name, 0) + weighted_sum += score * weight + + final_score = int(weighted_sum * (max_score / 10)) + + # Clamp score between min and max + return max(min(final_score, max_score), min_score) + def to_dict(self): """ Convert contest instance to dictionary for JSON serialization @@ -313,20 +393,25 @@ def to_dict(self): dict: Contest data """ return { - 'id': self.id, - 'name': self.name, - 'project_name': self.project_name, - 'created_by': self.created_by, - 'description': self.description, - 'start_date': self.start_date.isoformat() if self.start_date else None, - 'end_date': self.end_date.isoformat() if self.end_date else None, - 'rules': self.get_rules(), - 'marks_setting_accepted': self.marks_setting_accepted, - 'marks_setting_rejected': self.marks_setting_rejected, - 'allowed_submission_type': self.allowed_submission_type, - 'min_byte_count': self.min_byte_count, - 'categories': self.get_categories(), - 'jury_members': self.get_jury_members(), + "id": self.id, + "name": self.name, + "project_name": self.project_name, + "created_by": self.created_by, + "description": self.description, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "rules": self.get_rules(), + "scoring_parameters": ( + self.get_scoring_parameters() + if self.get_scoring_parameters() + else {"enabled": False} + ), + "marks_setting_accepted": self.marks_setting_accepted, + "marks_setting_rejected": self.marks_setting_rejected, + "allowed_submission_type": self.allowed_submission_type, + "min_byte_count": self.min_byte_count, + "categories": self.get_categories(), + "jury_members": self.get_jury_members(), # Format datetime as ISO string with 'Z' suffix to indicate UTC # This ensures JavaScript interprets it as UTC, not local time "created_at": ( diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 5d00240..780ba0a 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -4,6 +4,7 @@ """ from datetime import datetime, timezone +import json from app.database import db from app.models.base_model import BaseModel @@ -76,6 +77,9 @@ class Submission(BaseModel): back_populates="reviewed_submissions", overlaps="submissions", ) + parameter_scores = db.Column( + db.Text, nullable=True + ) # JSON string of parameter scores contest = db.relationship("Contest", back_populates="submissions") @@ -83,7 +87,10 @@ class Submission(BaseModel): # Allow multiple submissions per user per contest, but prevent duplicate article submissions __table_args__ = ( db.UniqueConstraint( - "user_id", "contest_id", "article_link", name="unique_user_contest_article_submission" + "user_id", + "contest_id", + "article_link", + name="unique_user_contest_article_submission", ), ) @@ -132,6 +139,7 @@ def __init__( self.reviewed_by = None self.reviewed_at = None self.review_comment = None + self.parameter_scores = None def is_pending(self): """ @@ -160,18 +168,35 @@ def is_rejected(self): """ return self.status == "rejected" + def set_parameter_scores(self, scores): + """Set individual parameter scores""" + if scores is None: + self.parameter_scores = None + elif isinstance(scores, dict): + self.parameter_scores = json.dumps(scores) + else: + self.parameter_scores = None + + def get_parameter_scores(self): + """Get individual parameter scores""" + if not self.parameter_scores: + return None + try: + return json.loads(self.parameter_scores) + except json.JSONDecodeError: + return None + def update_status( - self, new_status, reviewer=None, score=None, comment=None, contest=None + self, + new_status, + reviewer=None, + score=None, + comment=None, + contest=None, + parameter_scores=None, ): """ - Update submission status with review metadata - - Args: - new_status: accepted | rejected | auto_rejected - reviewer: User instance who reviewed - score: Optional score override - comment: Optional review comment - contest: Contest instance + MODIFY existing update_status method - ADD parameter_scores argument """ if self.status == new_status: return False @@ -179,13 +204,26 @@ def update_status( if not contest: contest = self.contest - # Determine final score - if new_status == "accepted": - final_score = score if score is not None else contest.marks_setting_accepted - elif new_status == "rejected": - final_score = contest.marks_setting_rejected - else: # auto_rejected - final_score = 0 + # ADD THIS BLOCK - Determine scoring method + if ( + contest.is_multi_parameter_scoring_enabled() + and new_status == "accepted" + and parameter_scores + ): + # NEW SYSTEM: Calculate from parameters + final_score = contest.calculate_weighted_score(parameter_scores) + self.set_parameter_scores(parameter_scores) + else: + # OLD SYSTEM: Use fixed scores + if new_status == "accepted": + final_score = ( + score if score is not None else contest.marks_setting_accepted + ) + elif new_status == "rejected": + final_score = contest.marks_setting_rejected + else: + final_score = 0 + self.parameter_scores = None score_difference = final_score - self.score @@ -201,6 +239,7 @@ def update_status( # Ensure submitter is loaded - if not, load it explicitly if self.submitter is None: from app.models.user import User + self.submitter = User.query.get(self.user_id) if self.submitter is None: raise ValueError(f"Submitter user with id {self.user_id} not found") @@ -283,7 +322,9 @@ def to_dict(self, include_user_info=False): # Article metadata for judges and organizers "article_author": self.article_author, "article_created_at": ( - (self.article_created_at.isoformat() + "Z") if self.article_created_at else None + (self.article_created_at.isoformat() + "Z") + if self.article_created_at + else None ), "article_word_count": self.article_word_count, "article_page_id": self.article_page_id, @@ -295,6 +336,7 @@ def to_dict(self, include_user_info=False): ), "review_comment": self.review_comment, "already_reviewed": self.reviewed_at is not None, + "parameter_scores": self.get_parameter_scores(), } if include_user_info: diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index 061d4f9..e2b47f9 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -203,6 +203,53 @@ def create_contest(): 400, ) + scoring_parameters = data.get("scoring_parameters") + if scoring_parameters: + if not isinstance(scoring_parameters, dict): + return jsonify({"error": "Scoring parameters must be an object"}), 400 + + if scoring_parameters.get("enabled"): + if "parameters" not in scoring_parameters: + return ( + jsonify( + {"error": 'Scoring parameters must include "parameters" array'} + ), + 400, + ) + + parameters = scoring_parameters["parameters"] + if not isinstance(parameters, list) or len(parameters) == 0: + return ( + jsonify({"error": "At least one scoring parameter is required"}), + 400, + ) + + # Validate weights + total_weight = 0 + for param in parameters: + if not isinstance(param, dict): + return jsonify({"error": "Each parameter must be an object"}), 400 + if "name" not in param or "weight" not in param: + return ( + jsonify( + {"error": 'Each parameter must have "name" and "weight"'} + ), + 400, + ) + try: + weight = int(param["weight"]) + if weight < 0 or weight > 100: + return jsonify({"error": f"Weight must be 0-100"}), 400 + total_weight += weight + except (ValueError, TypeError): + return jsonify({"error": "Weight must be a valid integer"}), 400 + + if total_weight != 100: + return ( + jsonify({"error": f"Weights must sum to 100, got {total_weight}"}), + 400, + ) + # Create contest try: contest = Contest( @@ -219,6 +266,7 @@ def create_contest(): allowed_submission_type=allowed_submission_type, min_byte_count=min_byte_count, categories=categories, + scoring_parameters=scoring_parameters, ) contest.save() diff --git a/backend/app/routes/submission_routes.py b/backend/app/routes/submission_routes.py index 0f53c3e..bbea2cd 100644 --- a/backend/app/routes/submission_routes.py +++ b/backend/app/routes/submission_routes.py @@ -11,7 +11,12 @@ from sqlalchemy.orm import joinedload from app.database import db -from app.middleware.auth import handle_errors, require_auth, require_submission_permission, validate_json_data +from app.middleware.auth import ( + handle_errors, + require_auth, + require_submission_permission, + validate_json_data, +) from app.models.contest import Contest from app.models.submission import Submission from app.utils import ( @@ -19,13 +24,14 @@ extract_page_title_from_url, get_latest_revision_author, build_mediawiki_revisions_api_params, - get_mediawiki_headers + get_mediawiki_headers, ) # Create blueprint -submission_bp = Blueprint('submission', __name__) +submission_bp = Blueprint("submission", __name__) + -@submission_bp.route('/', methods=['GET']) +@submission_bp.route("/", methods=["GET"]) @require_auth @handle_errors def get_all_submissions(): @@ -38,7 +44,7 @@ def get_all_submissions(): user = request.current_user if not user.is_admin(): - return jsonify({'error': 'Admin access required'}), 403 + return jsonify({"error": "Admin access required"}), 403 submissions = Submission.query.order_by(Submission.submitted_at.desc()).all() @@ -49,8 +55,9 @@ def get_all_submissions(): return jsonify(submissions_data), 200 -@submission_bp.route('/', methods=['GET']) -@require_submission_permission('view') + +@submission_bp.route("/", methods=["GET"]) +@require_submission_permission("view") @handle_errors def get_submission_by_id(submission_id): # pylint: disable=unused-argument """ @@ -68,7 +75,9 @@ def get_submission_by_id(submission_id): # pylint: disable=unused-argument submission_data = submission.to_dict(include_user_info=True) return jsonify(submission_data), 200 -#Temporarily disabling manual status update endpoint to enforce single source of truth + + +# Temporarily disabling manual status update endpoint to enforce single source of truth # @submission_bp.route('/', methods=['PUT']) # @require_submission_permission('jury') # @handle_errors @@ -123,7 +132,8 @@ def get_submission_by_id(submission_id): # pylint: disable=unused-argument # # Log error for debugging but don't expose details to client # return jsonify({'error': 'Failed to update submission'}), 500 -@submission_bp.route('/user/', methods=['GET']) + +@submission_bp.route("/user/", methods=["GET"]) @require_auth @handle_errors def get_user_submissions(user_id): @@ -140,11 +150,13 @@ def get_user_submissions(user_id): # Users can only view their own submissions unless they're admin if not current_user.is_admin() and current_user.id != user_id: - return jsonify({'error': 'You can only view your own submissions'}), 403 + return jsonify({"error": "You can only view your own submissions"}), 403 - submissions = Submission.query.filter_by(user_id=user_id).order_by( - Submission.submitted_at.desc() - ).all() + submissions = ( + Submission.query.filter_by(user_id=user_id) + .order_by(Submission.submitted_at.desc()) + .all() + ) submissions_data = [] for submission in submissions: @@ -153,13 +165,14 @@ def get_user_submissions(user_id): return jsonify(submissions_data), 200 -@submission_bp.route('/contest/', methods=['GET']) + +@submission_bp.route("/contest/", methods=["GET"]) @require_auth @handle_errors def get_contest_submissions(contest_id): """ Retrieve all submissions for a specific contest. - + This endpoint returns submissions with basic information. Access is restricted to admins, contest creators, and jury members. @@ -174,13 +187,17 @@ def get_contest_submissions(contest_id): # Validate contest access and permissions using shared utility function # This eliminates duplicate code across different route files # Note: contest variable is validated but not used in this route - _contest, error_response = validate_contest_submission_access(contest_id, user, Contest) + _contest, error_response = validate_contest_submission_access( + contest_id, user, Contest + ) if error_response: return error_response - submissions = Submission.query.filter_by(contest_id=contest_id).order_by( - Submission.submitted_at.desc() - ).all() + submissions = ( + Submission.query.filter_by(contest_id=contest_id) + .order_by(Submission.submitted_at.desc()) + .all() + ) submissions_data = [] for submission in submissions: @@ -189,7 +206,8 @@ def get_contest_submissions(contest_id): return jsonify(submissions_data), 200 -@submission_bp.route('/pending', methods=['GET']) + +@submission_bp.route("/pending", methods=["GET"]) @require_auth @handle_errors def get_pending_submissions(): @@ -202,7 +220,7 @@ def get_pending_submissions(): user = request.current_user # Get all pending submissions - pending_submissions = Submission.query.filter_by(status='pending').all() + pending_submissions = Submission.query.filter_by(status="pending").all() # Filter submissions that user can judge judgeable_submissions = [] @@ -213,7 +231,8 @@ def get_pending_submissions(): return jsonify(judgeable_submissions), 200 -@submission_bp.route('/stats', methods=['GET']) + +@submission_bp.route("/stats", methods=["GET"]) @require_auth @handle_errors def get_submission_stats(): @@ -228,45 +247,55 @@ def get_submission_stats(): # Get user's submission statistics total_submissions = Submission.query.filter_by(user_id=user.id).count() accepted_submissions = Submission.query.filter_by( - user_id=user.id, - status='accepted' + user_id=user.id, status="accepted" ).count() rejected_submissions = Submission.query.filter_by( - user_id=user.id, - status='rejected' + user_id=user.id, status="rejected" ).count() pending_submissions = Submission.query.filter_by( - user_id=user.id, - status='pending' + user_id=user.id, status="pending" ).count() # Get total score from submissions - total_score = db.session.query(db.func.sum(Submission.score)).filter_by( - user_id=user.id - ).scalar() or 0 - - return jsonify({ - 'total_submissions': total_submissions, - 'accepted_submissions': accepted_submissions, - 'rejected_submissions': rejected_submissions, - 'pending_submissions': pending_submissions, - 'total_score': total_score, - 'acceptance_rate': (accepted_submissions / total_submissions * 100) if total_submissions > 0 else 0 - }), 200 - -@submission_bp.route('/contest//refresh-metadata', methods=['POST']) + total_score = ( + db.session.query(db.func.sum(Submission.score)) + .filter_by(user_id=user.id) + .scalar() + or 0 + ) + + return ( + jsonify( + { + "total_submissions": total_submissions, + "accepted_submissions": accepted_submissions, + "rejected_submissions": rejected_submissions, + "pending_submissions": pending_submissions, + "total_score": total_score, + "acceptance_rate": ( + (accepted_submissions / total_submissions * 100) + if total_submissions > 0 + else 0 + ), + } + ), + 200, + ) + + +@submission_bp.route("/contest//refresh-metadata", methods=["POST"]) @require_auth @handle_errors def refresh_metadata(contest_id): """ Refresh article metadata (word count, author, etc.) for all submissions in a contest. - + This endpoint fetches the latest metadata from MediaWiki API for all submissions in the specified contest and updates the database with the current values. - + Args: contest_id: The ID of the contest to refresh submissions for - + Returns: JSON response with refresh results """ @@ -274,7 +303,9 @@ def refresh_metadata(contest_id): # Validate contest access and permissions # Note: contest variable is validated but not used in this route - _contest, error_response = validate_contest_submission_access(contest_id, user, Contest) + _contest, error_response = validate_contest_submission_access( + contest_id, user, Contest + ) if error_response: return error_response @@ -282,12 +313,17 @@ def refresh_metadata(contest_id): submissions = Submission.query.filter_by(contest_id=contest_id).all() if not submissions: - return jsonify({ - 'message': 'No submissions found for this contest', - 'updated': 0, - 'failed': 0, - 'total': 0 - }), 200 + return ( + jsonify( + { + "message": "No submissions found for this contest", + "updated": 0, + "failed": 0, + "total": 0, + } + ), + 200, + ) updated = 0 failed = 0 @@ -312,37 +348,39 @@ def fetch_article_info(article_link): # Get headers using shared utility function headers = get_mediawiki_headers() - response = requests.get(api_url, params=api_params, headers=headers, timeout=10) + response = requests.get( + api_url, params=api_params, headers=headers, timeout=10 + ) if response.status_code != 200: return None data = response.json() - if 'error' in data: + if "error" in data: return None - pages = data.get('query', {}).get('pages', []) + pages = data.get("query", {}).get("pages", []) if not pages: return None page_data = pages[0] - is_missing = page_data.get('missing', False) - page_id = str(page_data.get('pageid', '')) + is_missing = page_data.get("missing", False) + page_id = str(page_data.get("pageid", "")) - if is_missing or not page_id or page_id == '-1': + if is_missing or not page_id or page_id == "-1": return None # Get revision info # With rvdir='older', revisions[0] is the newest (latest) revision - revisions = page_data.get('revisions', []) + revisions = page_data.get("revisions", []) if not revisions or len(revisions) == 0: return None # Get latest revision (newest) for current size and author latest_revision = revisions[0] # Current size from latest revision (used for expansion bytes calculation on refresh) - current_size = latest_revision.get('size', 0) + current_size = latest_revision.get("size", 0) # Extract author from latest revision (newest revision at submission time) # Use shared utility function to extract author from latest revision @@ -350,7 +388,7 @@ def fetch_article_info(article_link): article_author = get_latest_revision_author(revisions) # Get latest revision timestamp - latest_revision_timestamp = latest_revision.get('timestamp', '') + latest_revision_timestamp = latest_revision.get("timestamp", "") # Get oldest revision (creation) for creation date (for historical reference) if len(revisions) > 1: @@ -359,14 +397,14 @@ def fetch_article_info(article_link): oldest_revision = revisions[0] return { - 'article_author': article_author, # Author from latest revision at submission time - 'article_created_at': oldest_revision.get('timestamp', ''), + "article_author": article_author, # Author from latest revision at submission time + "article_created_at": oldest_revision.get("timestamp", ""), # Current size from API (used for expansion bytes calculation, not stored as article_word_count) - 'current_size': current_size, - 'article_page_id': page_id, + "current_size": current_size, + "article_page_id": page_id, # Latest revision metadata (kept for backward compatibility) - 'latest_revision_author': article_author, # Same as article_author now - 'latest_revision_timestamp': latest_revision_timestamp + "latest_revision_author": article_author, # Same as article_author now + "latest_revision_timestamp": latest_revision_timestamp, } except Exception: # pylint: disable=broad-exception-caught @@ -376,7 +414,7 @@ def fetch_article_info(article_link): def calculate_expansion_bytes(submission_item, article_info): """ Calculate expansion bytes relative to submission time. - + Expansion bytes = current size - size at submission time (article_word_count) This shows how much the article has grown or shrunk since it was submitted. Only updates if there's an actual change in size. @@ -387,7 +425,7 @@ def calculate_expansion_bytes(submission_item, article_info): try: # Get current size from API (latest revision size) # This is the current/latest size of the article from the API - current_size = article_info.get('current_size') + current_size = article_info.get("current_size") # Get original size at submission time (article_word_count) # This is the size when the article was submitted @@ -411,8 +449,9 @@ def calculate_expansion_bytes(submission_item, article_info): # If expansion calculation fails, log but don't fail the update try: from flask import current_app + current_app.logger.warning( - f'Failed to calculate expansion for submission {submission_item.id}: {str(exp_error)}' + f"Failed to calculate expansion for submission {submission_item.id}: {str(exp_error)}" ) except Exception: # pylint: disable=broad-exception-caught pass @@ -425,17 +464,19 @@ def calculate_expansion_bytes(submission_item, article_info): # Update submission with latest metadata # Do NOT update article_author - it should remain fixed at submission time # Only update if it's not already set (for backward compatibility with old submissions) - if info.get('article_author') and not submission.article_author: - submission.article_author = info['article_author'] - if info.get('article_created_at') and not submission.article_created_at: + if info.get("article_author") and not submission.article_author: + submission.article_author = info["article_author"] + if info.get("article_created_at") and not submission.article_created_at: # Parse ISO 8601 timestamp string to datetime object - timestamp_str = info['article_created_at'] + timestamp_str = info["article_created_at"] if isinstance(timestamp_str, str): # MediaWiki API returns timestamps in ISO 8601 format with 'Z' suffix # Replace 'Z' with '+00:00' for UTC timezone, then parse - timestamp_str = timestamp_str.replace('Z', '+00:00') + timestamp_str = timestamp_str.replace("Z", "+00:00") try: - submission.article_created_at = datetime.fromisoformat(timestamp_str) + submission.article_created_at = datetime.fromisoformat( + timestamp_str + ) except (ValueError, AttributeError): # If parsing fails, set to None submission.article_created_at = None @@ -446,8 +487,8 @@ def calculate_expansion_bytes(submission_item, article_info): submission.article_created_at = None # Do NOT update article_word_count - it should remain fixed at submission time # article_word_count represents the size at the time of submission - if info.get('article_page_id'): - submission.article_page_id = info['article_page_id'] + if info.get("article_page_id"): + submission.article_page_id = info["article_page_id"] # Calculate expansion bytes on refresh # Expansion bytes = current size - size at submission time @@ -466,14 +507,20 @@ def calculate_expansion_bytes(submission_item, article_info): db.session.commit() except Exception: # pylint: disable=broad-exception-caught db.session.rollback() - return jsonify({'error': 'Failed to save updates to database'}), 500 + return jsonify({"error": "Failed to save updates to database"}), 500 + + return ( + jsonify( + { + "message": f"Refreshed metadata for {updated} submissions", + "updated": updated, + "failed": failed, + "total": len(submissions), + } + ), + 200, + ) - return jsonify({ - 'message': f'Refreshed metadata for {updated} submissions', - 'updated': updated, - 'failed': failed, - 'total': len(submissions) - }), 200 @submission_bp.route("//review", methods=["PUT"]) @require_auth @@ -483,14 +530,13 @@ def review_submission(submission_id): user = request.current_user data = request.validated_data - # Load submission with relationships to avoid lazy loading issues submission = Submission.query.options( joinedload(Submission.submitter), - joinedload(Submission.contest) + joinedload(Submission.contest), ).get_or_404(submission_id) contest = submission.contest - # ---- Permission ---- + # Permission checks if not submission.can_be_judged_by(user): return jsonify({"error": "Not allowed to review this submission"}), 403 @@ -498,43 +544,122 @@ def review_submission(submission_id): return jsonify({"error": "Submission already reviewed"}), 400 status = data.get("status") - score = data.get("score") comment = data.get("comment") if status not in ["accepted", "rejected"]: return jsonify({"error": "Invalid status"}), 400 + if contest.is_multi_parameter_scoring_enabled(): + scoring_config = contest.get_scoring_parameters() - # ---- Score validation ---- - if status == "accepted": - if score is None: - return jsonify({"error": "Score required for accepted submission"}), 400 + if status == "accepted": + parameter_scores = data.get("parameter_scores") - max_score = contest.marks_setting_accepted - if not isinstance(score, int) or score < 0 or score > max_score: - return jsonify({ - "error": f"Score must be between 0 and {max_score}" - }), 400 + if not parameter_scores: + return ( + jsonify({"error": "Parameter scores required for this contest"}), + 400, + ) + + if not isinstance(parameter_scores, dict): + return jsonify({"error": "Parameter scores must be an object"}), 400 + + # Validate all required parameters + required_params = [p["name"] for p in scoring_config.get("parameters", [])] + + for param_name in required_params: + if param_name not in parameter_scores: + return ( + jsonify({"error": f"Missing score for: {param_name}"}), + 400, + ) + + param_score = parameter_scores[param_name] + + if not isinstance(param_score, (int, float)): + return ( + jsonify({"error": f"Invalid score for {param_name}"}), + 400, + ) + + if param_score < 0 or param_score > 10: + return ( + jsonify( + { + "error": f"Score for {param_name} must be between 0 and 10" + } + ), + 400, + ) + + try: + # Final score is calculated INSIDE update_status + submission.update_status( + new_status=status, + reviewer=user, + comment=comment, + contest=contest, + parameter_scores=parameter_scores, + ) + except Exception as e: # pylint: disable=broad-exception-caught + db.session.rollback() + print(f"Review error (multi-parameter): {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + + else: + # Rejected → use min_score + min_score = scoring_config.get("min_score", 0) + + try: + submission.update_status( + new_status=status, + reviewer=user, + score=min_score, + comment=comment, + contest=contest, + ) + except Exception as e: # pylint: disable=broad-exception-caught + db.session.rollback() + print(f"Review error (rejected, multi): {str(e)}") + return jsonify({"error": "Internal server error"}), 500 else: - score = contest.marks_setting_rejected + if status == "accepted": + score = data.get("score") - # ✅ SINGLE SOURCE OF TRUTH - try: - submission.update_status( - new_status=status, - reviewer=user, - score=score, - comment=comment, - contest=contest - ) - except Exception as e: # pylint: disable=broad-exception-caught - db.session.rollback() - # Log the error for debugging - import traceback - print(f"Error updating submission status: {str(e)}") - print(traceback.format_exc()) - return jsonify({"error": "Internal server error"}), 500 - - return jsonify({ - "message": "Submission reviewed successfully", - "submission": submission.to_dict(include_user_info=True) - }), 200 + if score is None: + return jsonify({"error": "Score required"}), 400 + + max_score = contest.marks_setting_accepted + + if not isinstance(score, int): + return jsonify({"error": "Score must be an integer"}), 400 + + if score < 0 or score > max_score: + return ( + jsonify({"error": f"Score must be between 0 and {max_score}"}), + 400, + ) + else: + score = contest.marks_setting_rejected + + try: + submission.update_status( + new_status=status, + reviewer=user, + score=score, + comment=comment, + contest=contest, + ) + except Exception as e: # pylint: disable=broad-exception-caught + db.session.rollback() + print(f"Review error (simple): {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + + return ( + jsonify( + { + "message": "Submission reviewed successfully", + "submission": submission.to_dict(include_user_info=True), + } + ), + 200, + ) diff --git a/frontend/src/components/ArticlePreviewModal.vue b/frontend/src/components/ArticlePreviewModal.vue index 1993063..112a423 100644 --- a/frontend/src/components/ArticlePreviewModal.vue +++ b/frontend/src/components/ArticlePreviewModal.vue @@ -22,10 +22,7 @@ {{ error }} @@ -53,12 +50,8 @@ class="btn btn-sm btn-outline-primary">
- +
- - + Open in New Tab - - - - - - + + + + +