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
45 changes: 45 additions & 0 deletions backend/alembic/versions/549b0e507d5d_add_scoring_parameters.py
Original file line number Diff line number Diff line change
@@ -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')
123 changes: 104 additions & 19 deletions backend/app/models/contest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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", []))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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": (
Expand Down
78 changes: 60 additions & 18 deletions backend/app/models/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

from datetime import datetime, timezone
import json
from app.database import db
from app.models.base_model import BaseModel

Expand Down Expand Up @@ -76,14 +77,20 @@ 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")

# Constraints
# 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",
),
)

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -160,32 +168,62 @@ 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

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

Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading
Loading