Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions backend/alembic/versions/1fa03cdd51b_add_categories_to_contests.py
Original file line number Diff line number Diff line change
@@ -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')

34 changes: 34 additions & 0 deletions backend/app/models/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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", []))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion backend/app/routes/contest_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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')
Expand Down
86 changes: 81 additions & 5 deletions frontend/src/components/CreateContestModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ id="marksAccepted"
v-model.number="formData.marks_setting_accepted"
min="0" />
<small class="form-text text-muted">
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.
</small>
</div>
<div class="col-md-6 mb-3">
Expand Down Expand Up @@ -172,6 +173,41 @@ min="0"
<small class="form-text text-muted">Articles must have at least this many bytes</small>
</div>

<!-- Category URLs -->
<div class="mb-3">
<label class="form-label">
Category URLs *
<span class="text-muted">(MediaWiki category pages)</span>
</label>

<div v-for="(category, index) in formData.categories" :key="index" class="mb-2">
<div class="input-group">
<input type="url"
class="form-control"
v-model="formData.categories[index]"
:placeholder="index === 0 ? 'https://en.wikipedia.org/wiki/Category:Example' : 'Add another category URL'"
required />
<button v-if="formData.categories.length > 1"
type="button"
class="btn btn-outline-danger"
@click="removeCategory(index)"
title="Remove category">
<i class="fas fa-times"></i>
</button>
</div>
</div>

<button type="button"
class="btn btn-outline-primary btn-sm"
@click="addCategory">
<i class="fas fa-plus me-1"></i>Add Category
</button>

<small class="form-text text-muted d-block mt-2">
At least one MediaWiki category URL is required. Articles must belong to these categories.
</small>
</div>

</form>
</div>
<div class="modal-footer">
Expand Down Expand Up @@ -232,7 +268,8 @@ export default {
marks_setting_rejected: 0,
rules_text: '',
allowed_submission_type: 'both',
min_byte_count: 0
min_byte_count: 0,
categories: ['']
})

// Set default dates and ensure user is loaded
Expand Down Expand Up @@ -374,6 +411,18 @@ export default {
formData.jury_members = [...selectedJury.value]
}

// Add category field
const addCategory = () => {
formData.categories.push('')
}

// Remove category field
const removeCategory = (index) => {
if (formData.categories.length > 1) {
formData.categories.splice(index, 1)
}
}

// Handle form submission
const handleSubmit = async () => {
// Validation
Expand Down Expand Up @@ -401,11 +450,33 @@ export default {
showAlert('End date must be after start date', 'warning')
return
}
if (formData.min_byte_count === null || formData.min_byte_count === undefined || isNaN(formData.min_byte_count) || formData.min_byte_count < 0) {
showAlert('Minimum byte count is required and must be a non-negative number', 'warning')
if (
formData.min_byte_count === null ||
formData.min_byte_count === undefined ||
isNaN(formData.min_byte_count) ||
formData.min_byte_count < 0
) {
showAlert(
'Minimum byte count is required and must be a non-negative number',
'warning'
)
return
}

// Validate categories
const validCategories = formData.categories.filter(cat => cat && cat.trim())
if (validCategories.length === 0) {
showAlert('At least one category URL is required', 'warning')
return
}

// Validate category URLs
for (const category of validCategories) {
if (!category.startsWith('http://') && !category.startsWith('https://')) {
showAlert('All category URLs must be valid HTTP/HTTPS URLs', 'warning')
return
}
}

loading.value = true
try {
Expand All @@ -416,7 +487,9 @@ export default {
text: formData.rules_text.trim()
},
// Byte count field: required, must be a valid non-negative number
min_byte_count: Number(formData.min_byte_count)
min_byte_count: Number(formData.min_byte_count),
// Categories: filter out empty strings and trim
categories: formData.categories.filter(cat => cat && cat.trim()).map(cat => cat.trim())
}

const result = await store.createContest(contestData)
Expand Down Expand Up @@ -450,6 +523,7 @@ export default {
formData.jury_members = []
formData.rules_text = ''
formData.min_byte_count = 0
formData.categories = ['']

// Reset dates
const today = new Date()
Expand All @@ -472,6 +546,8 @@ export default {
searchJury,
addJury,
removeJury,
addCategory,
removeCategory,
handleSubmit,
store
}
Expand Down
Loading