From fc3ee89f50ae9f55964658b7b4b37bd7ab72a229 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Sun, 28 Dec 2025 12:00:33 +0530 Subject: [PATCH] Add categories to contests - Introduced a new migration to add a `categories` column to the `contests` table, allowing storage of MediaWiki category URLs as a JSON array. - Updated the `Contest` model to include the `categories` attribute, ensuring it is required and properly handled. - Enhanced contest creation and update routes to validate category URLs, ensuring at least one valid URL is provided. - Modified frontend components to support category input, including dynamic addition and removal of category fields, with validation for HTTP/HTTPS URLs. - Displayed required categories in the contest view, ensuring users are informed of the necessary categories for submissions. --- .../1fa03cdd51b_add_categories_to_contests.py | 55 ++++++ backend/app/models/contest.py | 34 ++++ backend/app/routes/contest_routes.py | 30 ++- .../src/components/CreateContestModal.vue | 86 ++++++++- frontend/src/views/ContestView.vue | 179 +++++++++++++++++- 5 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/1fa03cdd51b_add_categories_to_contests.py diff --git a/backend/alembic/versions/1fa03cdd51b_add_categories_to_contests.py b/backend/alembic/versions/1fa03cdd51b_add_categories_to_contests.py new file mode 100644 index 0000000..6862ff1 --- /dev/null +++ b/backend/alembic/versions/1fa03cdd51b_add_categories_to_contests.py @@ -0,0 +1,55 @@ +"""add_categories_to_contests + +Revision ID: 1fa03cdd51b +Revises: a1b2c3d4e5f6 +Create Date: 2025-12-29 14:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '1fa03cdd51b' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add categories column to contests table + # Categories will be stored as JSON array of category URLs in TEXT column + # Check if column exists before adding to handle partial migration scenarios + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('contests')] + + if 'categories' not in columns: + from sqlalchemy import text + # MySQL doesn't allow default values for TEXT columns + # So we add it as nullable first, then update existing rows, then make it NOT NULL + op.add_column('contests', sa.Column('categories', sa.Text(), nullable=True)) + + # Update any existing rows to have empty array + conn.execute(text("UPDATE contests SET categories = '[]' WHERE categories IS NULL OR categories = ''")) + + # Now make it NOT NULL + dialect_name = conn.dialect.name + if dialect_name == 'mysql': + conn.execute(text("ALTER TABLE contests MODIFY COLUMN categories TEXT NOT NULL")) + else: + op.alter_column('contests', 'categories', + existing_type=sa.Text(), + nullable=False, + existing_nullable=True) + + +def downgrade() -> None: + # Remove categories column + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('contests')] + + if 'categories' in columns: + op.drop_column('contests', 'categories') + diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 9559e77..21eaf2a 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -58,6 +58,10 @@ class Contest(BaseModel): # Articles must have byte count at least min_byte_count 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 + # Jury members (comma-separated usernames) jury_members = db.Column(db.Text, nullable=True) @@ -95,6 +99,9 @@ def __init__(self, name, project_name, created_by, **kwargs): # Articles must have byte count at least this value self.min_byte_count = kwargs.get('min_byte_count', 0) + # Handle categories (list of category URLs) + self.set_categories(kwargs.get("categories", [])) + # Handle rules and jury_members self.set_rules(kwargs.get("rules", {})) self.set_jury_members(kwargs.get("jury_members", [])) @@ -152,6 +159,32 @@ def get_jury_members(self): ] return [] + def set_categories(self, categories_list): + """ + Set contest categories from list + + Args: + categories_list: List of category URLs + """ + if isinstance(categories_list, list): + self.categories = json.dumps(categories_list) + else: + self.categories = json.dumps([]) + + def get_categories(self): + """ + Get contest categories as list + + Returns: + list: List of category URLs + """ + if self.categories: + try: + return json.loads(self.categories) + except json.JSONDecodeError: + return [] + return [] + def validate_byte_count(self, byte_count): """ Validate if article byte count meets the contest's minimum requirement @@ -292,6 +325,7 @@ def to_dict(self): '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 diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index b89c236..95daa22 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -172,6 +172,18 @@ def create_contest(): except (ValueError, TypeError): return jsonify({'error': 'Minimum byte count must be a valid integer'}), 400 + # Parse categories (required - list of category URLs) + categories = data.get('categories') + if not categories or not isinstance(categories, list) or len(categories) == 0: + return jsonify({'error': 'At least one category URL is required'}), 400 + + # Validate category URLs + for category_url in categories: + if not isinstance(category_url, str) or not category_url.strip(): + return jsonify({'error': 'All category URLs must be non-empty strings'}), 400 + if not (category_url.startswith('http://') or category_url.startswith('https://')): + return jsonify({'error': 'All category URLs must be valid HTTP/HTTPS URLs'}), 400 + # Create contest try: contest = Contest( @@ -186,7 +198,8 @@ def create_contest(): marks_setting_rejected=marks_rejected, jury_members=jury_members, allowed_submission_type=allowed_submission_type, - min_byte_count=min_byte_count + min_byte_count=min_byte_count, + categories=categories ) contest.save() @@ -428,6 +441,21 @@ def update_contest(contest_id): # pylint: disable=too-many-return-statements except (TypeError, ValueError): return jsonify({'error': 'min_byte_count must be a valid integer'}), 400 + # --- Categories --- + if 'categories' in data: + categories_value = data.get('categories') + if not categories_value or not isinstance(categories_value, list) or len(categories_value) == 0: + return jsonify({'error': 'At least one category URL is required'}), 400 + + # Validate category URLs + for category_url in categories_value: + if not isinstance(category_url, str) or not category_url.strip(): + return jsonify({'error': 'All category URLs must be non-empty strings'}), 400 + if not (category_url.startswith('http://') or category_url.startswith('https://')): + return jsonify({'error': 'All category URLs must be valid HTTP/HTTPS URLs'}), 400 + + contest.set_categories(categories_value) + # --- Jury members: accept list or comma string --- if 'jury_members' in data: jury_members_value = data.get('jury_members') diff --git a/frontend/src/components/CreateContestModal.vue b/frontend/src/components/CreateContestModal.vue index 6e6b255..644433e 100644 --- a/frontend/src/components/CreateContestModal.vue +++ b/frontend/src/components/CreateContestModal.vue @@ -142,7 +142,8 @@ id="marksAccepted" v-model.number="formData.marks_setting_accepted" min="0" /> - Maximum points that can be awarded. Jury can assign points from 0 up to this value for accepted submissions. + Maximum points that can be awarded. Jury can assign points from 0 up to + this value for accepted submissions.
@@ -172,6 +173,41 @@ min="0" Articles must have at least this many bytes
+ +
+ + +
+
+ + +
+
+ + + + + At least one MediaWiki category URL is required. Articles must belong to these categories. + +
+ + +
+
+
Required Categories
+
+
+

+ Articles must belong to the following MediaWiki categories: +

+ + + + Submitted articles must be categorized under at least one of these categories. + +
+
@@ -314,7 +339,7 @@ class="btn btn-sm btn-outline-primary"