From 6a1e615d2312a65767c82c98f37615325730ab82 Mon Sep 17 00:00:00 2001 From: avartirastogi Date: Fri, 6 Feb 2026 15:23:48 +0530 Subject: [PATCH 01/23] Implement Image and Infobox counting --- ...image_and_infobox_counts_to_submissions.py | 45 ++++++++++++++ backend/app/models/submission.py | 14 +++++ backend/app/routes/contest_routes.py | 31 ++++++++++ backend/app/utils/__init__.py | 59 +++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py b/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py new file mode 100644 index 00000000..a7269922 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py @@ -0,0 +1,45 @@ +"""Add image_count and infobox_count columns to submissions table + +Revision ID: f1a2b3c4d5e6 +Revises: e4e56960f418 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = "f1a2b3c4d5e6" +down_revision = "e4e56960f418" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "image_count" not in columns: + op.add_column( + "submissions", + sa.Column("image_count", sa.Integer(), nullable=True), + ) + + if "infobox_count" not in columns: + op.add_column( + "submissions", + sa.Column("infobox_count", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "infobox_count" in columns: + op.drop_column("submissions", "infobox_count") + + if "image_count" in columns: + op.drop_column("submissions", "image_count") diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index c520fd21..ff826740 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -70,6 +70,12 @@ class Submission(BaseModel): # Can be negative if article was reduced in size article_expansion_bytes = db.Column(db.Integer, nullable=True) + # Image count + image_count = db.Column(db.Integer, nullable=True) + + # Infobox count + infobox_count = db.Column(db.Integer, nullable=True) + # Template enforcement tracking # True if template was automatically added to the article during submission template_added = db.Column(db.Boolean, nullable=True, default=False) @@ -173,6 +179,8 @@ def __init__( template_added=False, categories_added=None, category_error=None, + image_count=None, + infobox_count=None, ): """ Initialize a new Submission instance @@ -192,6 +200,8 @@ def __init__( template_added: Whether template was automatically added to article (optional) categories_added: List of category names that were automatically added (optional, stored as JSON) category_error: Error message if category attachment failed (optional) + image_count: Number of images in the article (optional) + infobox_count: Number of infoboxes in the article (optional) """ # Set required fields self.user_id = user_id @@ -219,6 +229,8 @@ def __init__( else: self.categories_added = None self.category_error = category_error + self.image_count = image_count + self.infobox_count = infobox_count self.reviewed_by = None self.reviewed_at = None self.review_comment = None @@ -530,6 +542,8 @@ def to_dict(self, include_user_info=False): "article_page_id": self.article_page_id, "article_size_at_start": self.article_size_at_start, "article_expansion_bytes": self.article_expansion_bytes, + "image_count": self.image_count, + "infobox_count": self.infobox_count, "template_added": self.template_added, "categories_added": self.get_categories_added(), "category_error": self.category_error, diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index 77e580d5..b27e53c5 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -30,6 +30,8 @@ check_article_has_category, append_categories_to_article, get_article_reference_count, + get_article_image_count, + get_article_infobox_count, MEDIAWIKI_API_TIMEOUT, ) from app.services.outreach_dashboard import ( @@ -1301,6 +1303,8 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements article_size_at_start = None article_expansion_bytes = None article_reference_count = None + image_count = None + infobox_count = None # --- Fetch Article Information from MediaWiki API --- # MediaWiki API fetching has deep nesting due to complex error handling @@ -1686,6 +1690,31 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements pass article_reference_count = None + # --- Fetch Image and Infobox Counts --- + # These richness metrics are stored for future use but are not used in + # validation or scoring yet. + try: + image_count = get_article_image_count(article_link) + except Exception as img_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to fetch image count: %s", str(img_error) + ) + except Exception: # pylint: disable=broad-exception-caught + pass + image_count = None + + try: + infobox_count = get_article_infobox_count(article_link) + except Exception as ibx_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to fetch infobox count: %s", str(ibx_error) + ) + except Exception: # pylint: disable=broad-exception-caught + pass + infobox_count = None + # --- Validate Article Requirements --- # Validate article byte count against contest requirements # This check happens after fetching article information from MediaWiki API @@ -2047,6 +2076,8 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements template_added=template_added, categories_added=categories_added, category_error=category_error, + image_count=image_count, + infobox_count=infobox_count, ) submission.save() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index e67a9de4..146b97bb 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -44,6 +44,8 @@ "append_categories_to_article", "get_article_reference_count", "get_mediawiki_user_edit_count", + "get_article_image_count", + "get_article_infobox_count", ] @@ -948,6 +950,63 @@ def _fetch_footnotes_count(api_url: str, page_title: str, headers: dict) -> int: return 0 +def _log_warning(message: str, error: Exception) -> None: + """Best-effort logging helper that uses Flask current_app when available. + + This keeps network helpers free from hard Flask dependencies while still + providing useful diagnostics in a running application. + """ + try: + from flask import current_app + + current_app.logger.warning("%s: %s", message, str(error)) + except Exception: # pylint: disable=broad-exception-caught + # Logging must never break core logic, so ignore any logging failures + pass + + +def get_article_image_count(article_url: str) -> Optional[int]: + """ + The count is approximate and based purely on wikitext patterns; it does + not guarantee that every match results in a rendered image, but it + generally tracks user-added content images. + """ + try: + wikitext = get_article_wikitext(article_url) + if wikitext is None: + return None + + # Match explicit file/image links like [[File:Example.jpg|...]] or + # [[Image:Example.png|...]] in a case-insensitive way. + matches = re.findall(r'\[\[(?:File|Image):', wikitext, flags=re.IGNORECASE) + return len(matches) + + except Exception as error: # pylint: disable=broad-exception-caught + _log_warning("Failed to fetch image count", error) + return None + + +def get_article_infobox_count(article_url: str) -> Optional[int]: + """Count approximate number of infobox templates in article wikitext. + + Detection is done via a simple regex scan for ``{{infobox ...}}`` in the + raw wikitext. This is an approximation and may over-count or under-count + in edge cases (e.g. nested templates, unusual formatting), but is + sufficient for high-level richness metrics. + """ + try: + wikitext = get_article_wikitext(article_url) + if wikitext is None: + return None + + matches = re.findall(r"\{\{\s*infobox\b", wikitext, flags=re.IGNORECASE) + return len(matches) + + except Exception as error: # pylint: disable=broad-exception-caught + _log_warning("Failed to fetch infobox count", error) + return None + + def get_article_reference_count(article_url: str) -> Optional[int]: """ Get the total number of references in a MediaWiki article. From ed938afb644835e517fcf76162e69469dd23c3b9 Mon Sep 17 00:00:00 2001 From: avartirastogi Date: Fri, 13 Feb 2026 12:53:54 +0530 Subject: [PATCH 02/23] This feature counts incoming and outgoing links for the submitted article and saves it into the database. --- ...add_incoming_and_outgoing_links_columns.py | 46 +++++ backend/app/models/submission.py | 15 ++ backend/app/routes/contest_routes.py | 32 ++++ backend/app/utils/__init__.py | 176 ++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 backend/alembic/versions/195a1fb1d56e_add_incoming_and_outgoing_links_columns.py diff --git a/backend/alembic/versions/195a1fb1d56e_add_incoming_and_outgoing_links_columns.py b/backend/alembic/versions/195a1fb1d56e_add_incoming_and_outgoing_links_columns.py new file mode 100644 index 00000000..67e783b8 --- /dev/null +++ b/backend/alembic/versions/195a1fb1d56e_add_incoming_and_outgoing_links_columns.py @@ -0,0 +1,46 @@ +"""add incoming and outgoing links columns + +Revision ID: 195a1fb1d56e +Revises: f1a2b3c4d5e6 +Create Date: 2026-02-06 17:25:37.027375 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '195a1fb1d56e' +down_revision = "f1a2b3c4d5e6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "incoming_links" not in columns: + op.add_column( + "submissions", + sa.Column("incoming_links", sa.Integer(), nullable=True), + ) + + if "outgoing_links" not in columns: + op.add_column( + "submissions", + sa.Column("outgoing_links", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "outgoing_links" in columns: + op.drop_column("submissions", "outgoing_links") + + if "incoming_links" in columns: + op.drop_column("submissions", "incoming_links") diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index ff826740..e5051c6a 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -76,6 +76,13 @@ class Submission(BaseModel): # Infobox count infobox_count = db.Column(db.Integer, nullable=True) + # Link counts for future scoring evaluation + # Number of other mainspace articles that link to this article + incoming_links = db.Column(db.Integer, nullable=True) + + # Number of mainspace articles this article links to + outgoing_links = db.Column(db.Integer, nullable=True) + # Template enforcement tracking # True if template was automatically added to the article during submission template_added = db.Column(db.Boolean, nullable=True, default=False) @@ -181,6 +188,8 @@ def __init__( category_error=None, image_count=None, infobox_count=None, + incoming_links=None, + outgoing_links=None, ): """ Initialize a new Submission instance @@ -202,6 +211,8 @@ def __init__( category_error: Error message if category attachment failed (optional) image_count: Number of images in the article (optional) infobox_count: Number of infoboxes in the article (optional) + incoming_links: Number of other mainspace articles that link to this article (optional) + outgoing_links: Number of mainspace articles this article links to (optional) """ # Set required fields self.user_id = user_id @@ -231,6 +242,8 @@ def __init__( self.category_error = category_error self.image_count = image_count self.infobox_count = infobox_count + self.incoming_links = incoming_links + self.outgoing_links = outgoing_links self.reviewed_by = None self.reviewed_at = None self.review_comment = None @@ -544,6 +557,8 @@ def to_dict(self, include_user_info=False): "article_expansion_bytes": self.article_expansion_bytes, "image_count": self.image_count, "infobox_count": self.infobox_count, + "incoming_links": self.incoming_links, + "outgoing_links": self.outgoing_links, "template_added": self.template_added, "categories_added": self.get_categories_added(), "category_error": self.category_error, diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index b27e53c5..5eb6617d 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -32,6 +32,8 @@ get_article_reference_count, get_article_image_count, get_article_infobox_count, + get_article_incoming_links, + get_article_outgoing_links, MEDIAWIKI_API_TIMEOUT, ) from app.services.outreach_dashboard import ( @@ -1715,6 +1717,34 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements pass infobox_count = None + # --- Fetch Link Counts --- + # Count incoming and outgoing links for future scoring evaluation + # These metrics are stored but not used in validation or scoring yet + incoming_links = None + outgoing_links = None + + try: + incoming_links = get_article_incoming_links(article_link) + except Exception as incoming_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to fetch incoming links count: %s", str(incoming_error) + ) + except Exception: # pylint: disable=broad-exception-caught + pass + incoming_links = None + + try: + outgoing_links = get_article_outgoing_links(article_link) + except Exception as outgoing_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to fetch outgoing links count: %s", str(outgoing_error) + ) + except Exception: # pylint: disable=broad-exception-caught + pass + outgoing_links = None + # --- Validate Article Requirements --- # Validate article byte count against contest requirements # This check happens after fetching article information from MediaWiki API @@ -2078,6 +2108,8 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements category_error=category_error, image_count=image_count, infobox_count=infobox_count, + incoming_links=incoming_links, + outgoing_links=outgoing_links, ) submission.save() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 146b97bb..8f6391b2 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -46,6 +46,8 @@ "get_mediawiki_user_edit_count", "get_article_image_count", "get_article_infobox_count", + "get_article_incoming_links", + "get_article_outgoing_links", ] @@ -1489,3 +1491,177 @@ def append_categories_to_article( # pylint: disable=too-many-return-statements return result result['error'] = 'Unknown API response format' return result + + +def get_article_incoming_links(article_url: str) -> Optional[int]: + """ + Count the number of mainspace articles that link to the given article. + + This uses the MediaWiki API's "backlinks" query to count incoming links + from other articles in the main namespace (namespace 0). + + Args: + article_url: Full URL to the wiki article + + Returns: + Integer count of incoming links from mainspace articles, or None if fetch fails + """ + try: + # Extract page title from URL + page_title = extract_page_title_from_url(article_url) + if not page_title: + return None + + # Parse the article URL to extract base URL + url_obj = urlparse(article_url) + base_url = f"{url_obj.scheme}://{url_obj.netloc}" + api_url = f"{base_url}/w/api.php" + + # Build API parameters for backlinks query + params = { + "action": "query", + "format": "json", + "formatversion": "2", + "list": "backlinks", + "bltitle": page_title, + "blnamespace": "0", # Only count links from mainspace (namespace 0) + "bllimit": "500", # Maximum allowed by API + "blfilterredir": "nonredirects", # Exclude redirects + "redirects": "true", # Follow redirects to get actual page + "converttitles": "true", + } + + headers = get_mediawiki_headers() + + # Make initial request + response = requests.get(api_url, params=params, headers=headers, timeout=MEDIAWIKI_API_TIMEOUT) + if response.status_code != 200: + return None + + data = response.json() + if "error" in data: + return None + + # Count backlinks from response + backlinks = data.get("query", {}).get("backlinks", []) + total_count = len(backlinks) + + # Check if there are more results (continuation) + continue_params = data.get("continue") + while continue_params and total_count < 10000: # Safety limit to prevent infinite loops + # Update params with continuation token + params.update(continue_params) + + response = requests.get(api_url, params=params, headers=headers, timeout=MEDIAWIKI_API_TIMEOUT) + if response.status_code != 200: + break + + data = response.json() + if "error" in data: + break + + # Add more backlinks to count + more_backlinks = data.get("query", {}).get("backlinks", []) + total_count += len(more_backlinks) + + # Get next continuation token + continue_params = data.get("continue") + + return total_count + + except Exception: # pylint: disable=broad-exception-caught + # If link counting fails, return None to indicate failure + # This ensures submission process continues even if link counting fails + return None + + +def get_article_outgoing_links(article_url: str) -> Optional[int]: + """ + Count the number of mainspace articles that the given article links to. + + This uses the MediaWiki API's "links" query to count outgoing links + to other articles in the main namespace (namespace 0). + + Args: + article_url: Full URL to the wiki article + + Returns: + Integer count of outgoing links to mainspace articles, or None if fetch fails + """ + try: + # Extract page title from URL + page_title = extract_page_title_from_url(article_url) + if not page_title: + return None + + # Parse the article URL to extract base URL + url_obj = urlparse(article_url) + base_url = f"{url_obj.scheme}://{url_obj.netloc}" + api_url = f"{base_url}/w/api.php" + + # Build API parameters for links query + params = { + "action": "query", + "format": "json", + "formatversion": "2", + "prop": "links", + "titles": page_title, + "plnamespace": "0", # Only count links to mainspace (namespace 0) + "pllimit": "500", # Maximum allowed by API + "plfilterredir": "nonredirects", # Exclude redirects + "redirects": "true", # Follow redirects to get actual page + "converttitles": "true", + } + + headers = get_mediawiki_headers() + + # Make initial request + response = requests.get(api_url, params=params, headers=headers, timeout=MEDIAWIKI_API_TIMEOUT) + if response.status_code != 200: + return None + + data = response.json() + if "error" in data: + return None + + # Extract links from response + pages = data.get("query", {}).get("pages", []) + if not pages: + return None + + page_data = pages[0] + if page_data.get("missing", False): + return None + + links = page_data.get("links", []) + total_count = len(links) + + # Check if there are more results (continuation) + continue_params = data.get("continue") + while continue_params and total_count < 10000: # Safety limit to prevent infinite loops + # Update params with continuation token + params.update(continue_params) + + response = requests.get(api_url, params=params, headers=headers, timeout=MEDIAWIKI_API_TIMEOUT) + if response.status_code != 200: + break + + data = response.json() + if "error" in data: + break + + # Add more links to count + pages = data.get("query", {}).get("pages", []) + if pages: + more_links = pages[0].get("links", []) + total_count += len(more_links) + + # Get next continuation token + continue_params = data.get("continue") + + return total_count + + except Exception: # pylint: disable=broad-exception-caught + # If link counting fails, return None to indicate failure + # This ensures submission process continues even if link counting fails + return None From 52b7dc227415611f848b4c5ef147ff6b69f27189 Mon Sep 17 00:00:00 2001 From: avartirastogi Date: Sat, 28 Feb 2026 08:24:14 +0530 Subject: [PATCH 03/23] Add detailed reference counts to the database --- ...etailed_reference_counts_to_submissions.py | 46 +++++++++++++++++++ backend/app/models/submission.py | 12 +++++ backend/app/routes/contest_routes.py | 21 +++++++++ backend/app/routes/submission_routes.py | 18 +++++++- backend/app/utils/__init__.py | 23 ++++++++++ frontend/package-lock.json | 8 ---- 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/2b7c1a9d4c3e_add_detailed_reference_counts_to_submissions.py diff --git a/backend/alembic/versions/2b7c1a9d4c3e_add_detailed_reference_counts_to_submissions.py b/backend/alembic/versions/2b7c1a9d4c3e_add_detailed_reference_counts_to_submissions.py new file mode 100644 index 00000000..c961af7e --- /dev/null +++ b/backend/alembic/versions/2b7c1a9d4c3e_add_detailed_reference_counts_to_submissions.py @@ -0,0 +1,46 @@ +"""Add ref_new_count and ref_reused_count columns to submissions table + +Revision ID: 2b7c1a9d4c3e +Revises: 195a1fb1d56e + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = "2b7c1a9d4c3e" +down_revision = "195a1fb1d56e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "ref_new_count" not in columns: + op.add_column( + "submissions", + sa.Column("ref_new_count", sa.Integer(), nullable=True, server_default="0"), + ) + + if "ref_reused_count" not in columns: + op.add_column( + "submissions", + sa.Column("ref_reused_count", sa.Integer(), nullable=True, server_default="0"), + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = inspect(conn) + columns = [col["name"] for col in inspector.get_columns("submissions")] + + if "ref_reused_count" in columns: + op.drop_column("submissions", "ref_reused_count") + + if "ref_new_count" in columns: + op.drop_column("submissions", "ref_new_count") diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index e5051c6a..83c00a32 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -76,6 +76,10 @@ class Submission(BaseModel): # Infobox count infobox_count = db.Column(db.Integer, nullable=True) + # Reference Analysis Metrics + ref_new_count = db.Column(db.Integer, nullable=True, default=0) + ref_reused_count = db.Column(db.Integer, nullable=True, default=0) + # Link counts for future scoring evaluation # Number of other mainspace articles that link to this article incoming_links = db.Column(db.Integer, nullable=True) @@ -188,6 +192,8 @@ def __init__( category_error=None, image_count=None, infobox_count=None, + ref_new_count=0, + ref_reused_count=0, incoming_links=None, outgoing_links=None, ): @@ -211,6 +217,8 @@ def __init__( category_error: Error message if category attachment failed (optional) image_count: Number of images in the article (optional) infobox_count: Number of infoboxes in the article (optional) + ref_new_count: Number of new references added to the article (optional) + ref_reused_count: Number of reused references in the article (optional) incoming_links: Number of other mainspace articles that link to this article (optional) outgoing_links: Number of mainspace articles this article links to (optional) """ @@ -242,6 +250,8 @@ def __init__( self.category_error = category_error self.image_count = image_count self.infobox_count = infobox_count + self.ref_new_count = ref_new_count + self.ref_reused_count = ref_reused_count self.incoming_links = incoming_links self.outgoing_links = outgoing_links self.reviewed_by = None @@ -557,6 +567,8 @@ def to_dict(self, include_user_info=False): "article_expansion_bytes": self.article_expansion_bytes, "image_count": self.image_count, "infobox_count": self.infobox_count, + "ref_new_count": self.ref_new_count, + "ref_reused_count": self.ref_reused_count, "incoming_links": self.incoming_links, "outgoing_links": self.outgoing_links, "template_added": self.template_added, diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index 5eb6617d..c34b6690 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -30,6 +30,7 @@ check_article_has_category, append_categories_to_article, get_article_reference_count, + get_detailed_reference_counts, get_article_image_count, get_article_infobox_count, get_article_incoming_links, @@ -1305,6 +1306,8 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements article_size_at_start = None article_expansion_bytes = None article_reference_count = None + ref_new_count = 0 + ref_reused_count = 0 image_count = None infobox_count = None @@ -1692,6 +1695,22 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements pass article_reference_count = None + # --- Fetch Detailed Reference Counts --- + # Counts ... as new refs, and as reused refs + try: + ref_counts = get_detailed_reference_counts(article_link) + ref_new_count = int(ref_counts.get("new", 0) or 0) + ref_reused_count = int(ref_counts.get("reused", 0) or 0) + except Exception as ref_detail_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to fetch detailed reference counts: %s", str(ref_detail_error) + ) + except Exception: # pylint: disable=broad-exception-caught + pass + ref_new_count = 0 + ref_reused_count = 0 + # --- Fetch Image and Infobox Counts --- # These richness metrics are stored for future use but are not used in # validation or scoring yet. @@ -2108,6 +2127,8 @@ def submit_to_contest(contest_id): # pylint: disable=too-many-return-statements category_error=category_error, image_count=image_count, infobox_count=infobox_count, + ref_new_count=ref_new_count, + ref_reused_count=ref_reused_count, incoming_links=incoming_links, outgoing_links=outgoing_links, ) diff --git a/backend/app/routes/submission_routes.py b/backend/app/routes/submission_routes.py index 20f70761..4b03b5c6 100644 --- a/backend/app/routes/submission_routes.py +++ b/backend/app/routes/submission_routes.py @@ -25,7 +25,8 @@ get_latest_revision_author, build_mediawiki_revisions_api_params, get_mediawiki_headers, - MEDIAWIKI_API_TIMEOUT + get_detailed_reference_counts, + MEDIAWIKI_API_TIMEOUT, ) # Create blueprint @@ -460,6 +461,21 @@ def calculate_expansion_bytes(submission_item, article_info): # These should remain fixed at submission time calculate_expansion_bytes(submission, info) + # Update detailed reference counts on refresh + try: + ref_counts = get_detailed_reference_counts(submission.article_link) + submission.ref_new_count = int(ref_counts.get("new", 0) or 0) + submission.ref_reused_count = int(ref_counts.get("reused", 0) or 0) + except Exception as ref_error: # pylint: disable=broad-exception-caught + try: + current_app.logger.warning( + "Failed to refresh reference counts for submission %s: %s", + submission.id, + str(ref_error), + ) + except Exception: # pylint: disable=broad-exception-caught + pass + updated += 1 else: failed += 1 diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index 8f6391b2..0c6c6f0e 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -43,6 +43,7 @@ "check_article_has_category", "append_categories_to_article", "get_article_reference_count", + "get_detailed_reference_counts", "get_mediawiki_user_edit_count", "get_article_image_count", "get_article_infobox_count", @@ -163,6 +164,8 @@ def extract_page_title_from_url(article_url: str) -> Optional[str]: return None + + def build_mediawiki_revisions_api_params(page_title: str) -> Dict[str, Any]: # pylint: disable=invalid-name """ Build a standard parameter set for MediaWiki `revisions` queries. @@ -388,6 +391,9 @@ def get_article_wikitext(article_url: str) -> Optional[str]: # pylint: disable= Returns: Wikitext content as string, or None if fetch fails. """ + if not article_url: + return None + page_title = extract_page_title_from_url(article_url) if not page_title: return None @@ -444,6 +450,23 @@ def get_article_wikitext(article_url: str) -> Optional[str]: # pylint: disable= return content +def get_detailed_reference_counts(article_url: str) -> Dict[str, int]: + """Parses wikitext to distinguish between New (defined) and Reused (self-closing) references.""" + wikitext = get_article_wikitext(article_url) + if not wikitext: + return {"new": 0, "reused": 0} + + text = re.sub(r"", "", wikitext, flags=re.DOTALL) + + new_refs_pattern = r"]*?>.*?" + new_refs = len(re.findall(new_refs_pattern, text, flags=re.IGNORECASE | re.DOTALL)) + + reused_refs_pattern = r"]*?/>" + reused_refs = len(re.findall(reused_refs_pattern, text, flags=re.IGNORECASE)) + + return {"new": new_refs, "reused": reused_refs} + + def check_article_has_template(article_url: str, template_name: str) -> Dict[str, Any]: """ Check if an article begins with the specified template. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f603902f..e38d06e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1084,7 +1084,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1881,7 +1880,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2078,7 +2076,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -2136,7 +2133,6 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -2176,7 +2172,6 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -2193,7 +2188,6 @@ "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "globals": "^13.24.0", @@ -4521,7 +4515,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4581,7 +4574,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", From 2ac66d21b20d9e39c3fc3b95d822f5f7e004a0b3 Mon Sep 17 00:00:00 2001 From: avartirastogi Date: Mon, 16 Mar 2026 18:10:11 +0530 Subject: [PATCH 04/23] Fix the alembic migrations and repush the old commits --- ...1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py b/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py index a7269922..9bdfdf63 100644 --- a/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_image_and_infobox_counts_to_submissions.py @@ -1,7 +1,7 @@ """Add image_count and infobox_count columns to submissions table Revision ID: f1a2b3c4d5e6 -Revises: e4e56960f418 +Revises: b3f7a9c2d1e4 """ from alembic import op @@ -10,7 +10,7 @@ revision = "f1a2b3c4d5e6" -down_revision = "e4e56960f418" +down_revision = "b3f7a9c2d1e4" branch_labels = None depends_on = None From 96509da6e9747135074b4b18559722008f61c541 Mon Sep 17 00:00:00 2001 From: hridyesh-gupta Date: Fri, 20 Mar 2026 15:53:56 +0530 Subject: [PATCH 05/23] DB ready for autoscoring --- ...5a2313fbd_add_automated_settings_column.py | 61 ++++++++++ backend/app/models/contest.py | 16 ++- backend/app/models/contest_mixin.py | 56 +++++++++ backend/app/routes/contest_routes.py | 114 ++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0f85a2313fbd_add_automated_settings_column.py diff --git a/backend/alembic/versions/0f85a2313fbd_add_automated_settings_column.py b/backend/alembic/versions/0f85a2313fbd_add_automated_settings_column.py new file mode 100644 index 00000000..8fad7a57 --- /dev/null +++ b/backend/alembic/versions/0f85a2313fbd_add_automated_settings_column.py @@ -0,0 +1,61 @@ +"""add_automated_settings_column + +Revision ID: 0f85a2313fbd +Revises: 2b7c1a9d4c3e +Create Date: 2026-03-17 + +Adds automated_settings column to contests table for automated scoring mode. +This column stores eligibility criteria and evaluation parameters as JSON. +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '0f85a2313fbd' +down_revision = '2b7c1a9d4c3e' +branch_labels = None +depends_on = None + + +def upgrade(): + """ + Add automated_settings column to contests table. + + Structure stored as JSON: + { + "enabled": true, + "eligibility": { + "min_edits": 100, + "min_bytes": 5000, + "min_incoming_links": 2, + "min_outgoing_links": 3 + }, + "evaluation": { + "points_per_accepted": 10, + "points_per_byte": 0.001, + "points_per_incoming_link": 2, + "points_per_outgoing_link": 1, + "points_per_category": 1, + "points_per_new_reference": 3, + "points_per_reused_reference": 1, + "points_per_infobox": 5, + "points_per_image": 2 + } + } + """ + op.add_column( + 'contests', + sa.Column( + 'automated_settings', + sa.Text(), + nullable=True, + comment='Automated scoring configuration (eligibility + evaluation criteria)' + ) + ) + + +def downgrade(): + """Remove automated_settings column from contests table.""" + op.drop_column('contests', 'automated_settings') diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 87afec41..110369e4 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -71,6 +71,10 @@ class Contest(BaseModel, ContestMixin): # Multi-parameter scoring configuration (stored as JSON string) scoring_parameters = db.Column(db.Text, nullable=True) + # Automated scoring configuration (stored as JSON string) + # Contains eligibility criteria and evaluation parameters + automated_settings = db.Column(db.Text, nullable=True) + # Submission type restriction: 'new', 'expansion', or 'both' allowed_submission_type = db.Column(db.String(20), default="both", nullable=False) @@ -155,6 +159,9 @@ def __init__(self, name, project_name, created_by, **kwargs): # Set scoring configuration (handles validation internally) self.set_scoring_parameters(kwargs.get("scoring_parameters")) + # Set automated scoring configuration (handles validation internally) + self.set_automated_settings(kwargs.get("automated_settings")) + # Set article requirements self.min_byte_count = kwargs.get("min_byte_count", 0) self.min_reference_count = kwargs.get("min_reference_count", 0) @@ -563,8 +570,13 @@ def get_scoring_mode(self): Get the current scoring mode for this contest. Returns: - str: 'simple' or 'multi_parameter' + str: 'simple', 'multi_parameter', or 'automated' """ + # Check for automated scoring mode first + automated = self.get_automated_settings() + if automated and automated.get("enabled") is True: + return "automated" + # Then check for multi-parameter scoring params = self.get_scoring_parameters() if params and params.get("enabled") is True: return "multi_parameter" @@ -607,6 +619,8 @@ def to_dict(self): ), # CRITICAL FIX: Explicitly add scoring_parameters "scoring_parameters": scoring_params, + # Automated scoring settings + "automated_settings": self.get_automated_settings(), # Computed fields "submission_count": self.get_submission_count(), "status": self.get_status(), diff --git a/backend/app/models/contest_mixin.py b/backend/app/models/contest_mixin.py index 0adb606c..cc73473d 100644 --- a/backend/app/models/contest_mixin.py +++ b/backend/app/models/contest_mixin.py @@ -168,6 +168,62 @@ def get_scoring_parameters(self): return None + # ------------------------------------------------------------------------ + # AUTOMATED SETTINGS MANAGEMENT (JSON Dictionary Storage) + # ------------------------------------------------------------------------ + + def set_automated_settings(self, settings): + """ + Set automated scoring settings configuration + + Args: + settings: Dict or None containing automated scoring configuration + { + "enabled": true, + "eligibility": { + "min_edits": 100, + "min_bytes": 5000, + "min_incoming_links": 2, + "min_outgoing_links": 3 + }, + "evaluation": { + "points_per_accepted": 10, + "points_per_byte": 0.001, + "points_per_incoming_link": 2, + "points_per_outgoing_link": 1, + "points_per_category": 1, + "points_per_new_reference": 3, + "points_per_reused_reference": 1, + "points_per_infobox": 5, + "points_per_image": 2 + } + } + """ + if settings is None: + self.automated_settings = None + elif isinstance(settings, dict): + # Store as JSON string + self.automated_settings = json.dumps(settings) + else: + self.automated_settings = None + + + def get_automated_settings(self): + """ + Get automated scoring settings configuration + + Returns: + dict or None: Automated scoring settings configuration + """ + if not self.automated_settings: + return None + try: + # Parse JSON string back to dictionary + return json.loads(self.automated_settings) + except json.JSONDecodeError: + return None + + # ------------------------------------------------------------------------ # ORGANIZERS MANAGEMENT (Comma-Separated Storage) # ------------------------------------------------------------------------ diff --git a/backend/app/routes/contest_routes.py b/backend/app/routes/contest_routes.py index c34b6690..5f6b0850 100644 --- a/backend/app/routes/contest_routes.py +++ b/backend/app/routes/contest_routes.py @@ -742,6 +742,63 @@ def create_contest(): # If no scoring_parameters provided, set to None (will use simple scoring) scoring_parameters = None + # ----------------------------------------------------------------------- + # Validate Automated Settings (Optional) + # ----------------------------------------------------------------------- + + automated_settings = data.get("automated_settings") + + if automated_settings: + if not isinstance(automated_settings, dict): + return jsonify({"error": "Automated settings must be an object"}), 400 + + # Validate automated scoring structure if enabled + if automated_settings.get("enabled"): + current_app.logger.info(f"[AUTOMATED CREATE] Automated scoring enabled") + + # Validate eligibility section + eligibility = automated_settings.get("eligibility", {}) + if not isinstance(eligibility, dict): + eligibility = {} + + # Validate evaluation section + evaluation = automated_settings.get("evaluation", {}) + if not isinstance(evaluation, dict): + evaluation = {} + + # Validate numeric values in eligibility + for field in ["min_edits", "min_bytes", "min_incoming_links", "min_outgoing_links"]: + value = eligibility.get(field) + if value is not None: + try: + eligibility[field] = int(value) + if eligibility[field] < 0: + return jsonify({"error": f"{field} must be non-negative"}), 400 + except (ValueError, TypeError): + return jsonify({"error": f"{field} must be a valid integer"}), 400 + + # Validate numeric values in evaluation + for field in ["points_per_accepted", "points_per_byte", "points_per_incoming_link", + "points_per_outgoing_link", "points_per_category", "points_per_new_reference", + "points_per_reused_reference", "points_per_infobox", "points_per_image"]: + value = evaluation.get(field) + if value is not None: + try: + evaluation[field] = float(value) + if evaluation[field] < 0: + return jsonify({"error": f"{field} must be non-negative"}), 400 + except (ValueError, TypeError): + return jsonify({"error": f"{field} must be a valid number"}), 400 + + # Update with validated values + automated_settings["eligibility"] = eligibility + automated_settings["evaluation"] = evaluation + + # When automated mode is enabled, scoring_parameters should be null + scoring_parameters = None + else: + automated_settings = None + # ----------------------------------------------------------------------- # Create Contest # ----------------------------------------------------------------------- @@ -810,6 +867,7 @@ def create_contest(): template_link=template_link, outreach_dashboard_url=outreach_dashboard_url, scoring_parameters=scoring_parameters, + automated_settings=automated_settings, organizers=additional_organizers, min_reference_count=min_reference_count, ) @@ -1199,6 +1257,62 @@ def update_contest(contest_id): except ValueError as ve: return jsonify({"error": str(ve)}), 400 + # --- Automated Settings (Automated Scoring Mode) --- + if "automated_settings" in data: + as_settings = data.get("automated_settings") + + # Accept explicit null to disable automated settings + if as_settings is None: + contest.set_automated_settings(None) + elif not isinstance(as_settings, dict): + return jsonify({"error": "automated_settings must be an object"}), 400 + else: + # Validate automated scoring structure if enabled + if as_settings.get("enabled"): + # Validate eligibility section + eligibility = as_settings.get("eligibility", {}) + if not isinstance(eligibility, dict): + eligibility = {} + + # Validate evaluation section + evaluation = as_settings.get("evaluation", {}) + if not isinstance(evaluation, dict): + evaluation = {} + + # Validate numeric values in eligibility + for field in ["min_edits", "min_bytes", "min_incoming_links", "min_outgoing_links"]: + value = eligibility.get(field) + if value is not None: + try: + eligibility[field] = int(value) + if eligibility[field] < 0: + return jsonify({"error": f"{field} must be non-negative"}), 400 + except (ValueError, TypeError): + return jsonify({"error": f"{field} must be a valid integer"}), 400 + + # Validate numeric values in evaluation + for field in ["points_per_accepted", "points_per_byte", "points_per_incoming_link", + "points_per_outgoing_link", "points_per_category", "points_per_new_reference", + "points_per_reused_reference", "points_per_infobox", "points_per_image"]: + value = evaluation.get(field) + if value is not None: + try: + evaluation[field] = float(value) + if evaluation[field] < 0: + return jsonify({"error": f"{field} must be non-negative"}), 400 + except (ValueError, TypeError): + return jsonify({"error": f"{field} must be a valid number"}), 400 + + # Update with validated values + as_settings["eligibility"] = eligibility + as_settings["evaluation"] = evaluation + + # When automated mode is enabled, disable multi-parameter scoring + contest.set_scoring_parameters(None) + + # Persist validated automated settings + contest.set_automated_settings(as_settings) + # --- Organizers --- if "organizers" in data: organizers_payload = data.get("organizers") From 359280a9426b8a1345cf78843794a37a69b4d499 Mon Sep 17 00:00:00 2001 From: hridyesh-gupta Date: Fri, 20 Mar 2026 16:32:33 +0530 Subject: [PATCH 06/23] Frontend ready for automated scoring --- .../src/components/CreateContestModal.vue | 330 ++++++++++++++++-- frontend/src/views/ContestView.vue | 225 +++++++++++- 2 files changed, 523 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/CreateContestModal.vue b/frontend/src/components/CreateContestModal.vue index 4945c365..eac98f46 100644 --- a/frontend/src/components/CreateContestModal.vue +++ b/frontend/src/components/CreateContestModal.vue @@ -107,34 +107,48 @@ required
-
-
@@ -856,6 +868,73 @@ title="Delete Submission"
+ + + +
+
+
Import Articles from Category
+
+
+

Import articles from the contest's configured categories as pending submissions.

+ +
+ + + +
+ +
+
+ + {{ crawlResult.message }} +
+ Category: {{ crawlResult.category }} +
+ Skipped (duplicates): {{ crawlResult.skipped }} +
+
+ + All {{ crawlResult.skipped }} articles were duplicates +
+
+ + No articles found in category +
+
+
+ + {{ crawlError }} +
+
+
+
@@ -1674,6 +1753,14 @@ import SubmitArticleModal from '../components/SubmitArticleModal.vue' import ArticlePreviewModal from '../components/ArticlePreviewModal.vue' import OutreachDashboardTab from '../components/OutreachDashboardTab.vue' +// Helper function to get CSRF token from cookies +function getCookie(name) { + return document.cookie + .split('; ') + .find(row => row.startsWith(name + '=')) + ?.split('=')[1] +} + export default { name: 'ContestView', components: { @@ -1800,7 +1887,7 @@ export default { } // Category crawler state - const categoryUrl = ref('') + const selectedCategory = ref('') const crawlLimit = ref(100) const crawling = ref(false) const crawlResult = ref(null) @@ -1808,21 +1895,23 @@ export default { // Crawl category and import articles const crawlCategory = async () => { - if (!categoryUrl.value) return + if (!selectedCategory.value) return crawling.value = true crawlResult.value = null crawlError.value = null try { + const csrfToken = getCookie('csrf_access_token') const response = await fetch(`/api/contest/${contest.value.id}/crawl-category`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken || '' }, credentials: 'include', body: JSON.stringify({ - category_url: categoryUrl.value, + category_url: selectedCategory.value, limit: crawlLimit.value || 100 }) }) @@ -1894,6 +1983,29 @@ export default { return false }) + // Determine if user can import articles (jury member or superadmin only) + const canImportArticles = computed(() => { + if (!isAuthenticated.value || !contest.value || !currentUser.value) { + return false + } + + const username = (currentUser.value.username || '').trim().toLowerCase() + const role = (currentUser.value.role || '').trim().toLowerCase() + + // Superadmin can always import + if (role === 'superadmin') { + return true + } + + // Jury members can import + if (contest.value.jury_members && Array.isArray(contest.value.jury_members)) { + const juryUsernames = contest.value.jury_members.map(j => (j || '').trim().toLowerCase()) + return juryUsernames.includes(username) + } + + return false + }) + // Determine if user can delete contest (creator or admin/superadmin) const checkDeletePermission = () => { canDeleteContest.value = false @@ -2873,12 +2985,12 @@ export default { automatedSettings, loadDefaultAutomatedSettings, // Category crawler - categoryUrl, + selectedCategory, crawlLimit, crawling, crawlResult, crawlError, - crawlCategory + canImportArticles, } } } @@ -3538,6 +3650,21 @@ export default { flex-wrap: wrap; } +.crawler-select { + flex: 1; + min-width: 280px; + padding: 0.625rem 0.875rem; + border-radius: 6px; + border: 1px solid #d1d5db; + font-size: 0.875rem; +} + +[data-theme="dark"] .crawler-select { + background: #1f1f1f; + border-color: #404040; + color: #e5e7eb; +} + .crawler-input { flex: 1; min-width: 280px; From 20b052da1aaf00baddaacbe3503c3423fa19d02c Mon Sep 17 00:00:00 2001 From: hridyesh-gupta Date: Wed, 25 Mar 2026 19:33:56 +0530 Subject: [PATCH 11/23] Implement automated scoring feature for the articles --- ...3c4d5e6f_add_evaluation_details_columns.py | 46 +++ backend/app/models/contest.py | 128 ++++++++ backend/app/models/submission.py | 26 ++ backend/app/routes/submission_routes.py | 104 ++++++- frontend/src/views/ContestView.vue | 274 ++++++++++++++++-- 5 files changed, 555 insertions(+), 23 deletions(-) create mode 100644 backend/alembic/versions/1a2b3c4d5e6f_add_evaluation_details_columns.py diff --git a/backend/alembic/versions/1a2b3c4d5e6f_add_evaluation_details_columns.py b/backend/alembic/versions/1a2b3c4d5e6f_add_evaluation_details_columns.py new file mode 100644 index 00000000..d6d44037 --- /dev/null +++ b/backend/alembic/versions/1a2b3c4d5e6f_add_evaluation_details_columns.py @@ -0,0 +1,46 @@ +"""add_evaluation_details_columns + +Revision ID: 1a2b3c4d5e6f +Revises: 0f85a2313fbd +Create Date: 2026-03-21 + +Adds evaluation_reason and score_breakdown columns to submissions table +for displaying automated scoring details. +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '1a2b3c4d5e6f' +down_revision = '0f85a2313fbd' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add evaluation_reason and score_breakdown columns to submissions table.""" + op.add_column( + 'submissions', + sa.Column( + 'evaluation_reason', + sa.Text(), + nullable=True, + comment='Reason for rejection or success message from automated evaluation' + ) + ) + op.add_column( + 'submissions', + sa.Column( + 'score_breakdown', + sa.Text(), + nullable=True, + comment='JSON breakdown of score calculation for automated scoring' + ) + ) + + +def downgrade(): + """Remove evaluation_reason and score_breakdown columns from submissions table.""" + op.drop_column('submissions', 'score_breakdown') + op.drop_column('submissions', 'evaluation_reason') diff --git a/backend/app/models/contest.py b/backend/app/models/contest.py index 110369e4..b17bf10c 100644 --- a/backend/app/models/contest.py +++ b/backend/app/models/contest.py @@ -582,6 +582,134 @@ def get_scoring_mode(self): return "multi_parameter" return "simple" + # ------------------------------------------------------------------------ + # AUTOMATED EVALUATION ENGINE + # ------------------------------------------------------------------------ + + def evaluate_automated_submission(self, submission_data): + """ + Evaluate a submission against automated scoring criteria. + + Checks eligibility first, then calculates score if eligible. + + Args: + submission_data: Dict containing submission metadata: + - article_word_count: Article size in bytes + - incoming_links: Number of incoming links + - outgoing_links: Number of outgoing links + - ref_new_count: Number of new references + - ref_reused_count: Number of reused references + - image_count: Number of images + - infobox_count: Number of infoboxes + + Returns: + tuple: (is_eligible: bool, final_score: float, reason: str, breakdown: dict or None) + """ + automated = self.get_automated_settings() + if not automated or not automated.get("enabled"): + return False, 0, "Automated scoring not enabled for this contest", None + + eligibility = automated.get("eligibility", {}) + evaluation = automated.get("evaluation", {}) + + # --- ELIGIBILITY CHECKS --- + reasons = [] + + # Check minimum byte count (article_word_count stores bytes) + min_bytes = eligibility.get("min_bytes", 0) + actual_bytes = submission_data.get("article_word_count") or 0 + if min_bytes > 0 and actual_bytes < min_bytes: + reasons.append(f"Article size ({actual_bytes} bytes) below minimum ({min_bytes} bytes)") + + # Check minimum incoming links + min_incoming = eligibility.get("min_incoming_links", 0) + actual_incoming = submission_data.get("incoming_links") or 0 + if min_incoming > 0 and actual_incoming < min_incoming: + reasons.append(f"Incoming links ({actual_incoming}) below minimum ({min_incoming})") + + # Check minimum outgoing links + min_outgoing = eligibility.get("min_outgoing_links", 0) + actual_outgoing = submission_data.get("outgoing_links") or 0 + if min_outgoing > 0 and actual_outgoing < min_outgoing: + reasons.append(f"Outgoing links ({actual_outgoing}) below minimum ({min_outgoing})") + + # Check minimum references + min_refs = eligibility.get("min_references", 0) + actual_refs = (submission_data.get("ref_new_count") or 0) + (submission_data.get("ref_reused_count") or 0) + if min_refs > 0 and actual_refs < min_refs: + reasons.append(f"Total references ({actual_refs}) below minimum ({min_refs})") + + # If any eligibility check failed, return rejected + if reasons: + return False, 0, "; ".join(reasons), None + + # --- SCORE CALCULATION --- + score = 0.0 + breakdown = {} + + # Points per accepted article (base points) + base_points = float(evaluation.get("points_per_accepted", 0)) + score += base_points + breakdown["base_points"] = base_points + + # Points per byte + points_per_byte = float(evaluation.get("points_per_byte", 0)) + bytes_points = round(actual_bytes * points_per_byte, 2) + score += bytes_points + breakdown["bytes_points"] = bytes_points + breakdown["bytes_count"] = actual_bytes + + # Points per incoming link + points_per_incoming = float(evaluation.get("points_per_incoming_link", 0)) + incoming_points = round(actual_incoming * points_per_incoming, 2) + score += incoming_points + breakdown["incoming_links_points"] = incoming_points + breakdown["incoming_links_count"] = actual_incoming + + # Points per outgoing link + points_per_outgoing = float(evaluation.get("points_per_outgoing_link", 0)) + outgoing_points = round(actual_outgoing * points_per_outgoing, 2) + score += outgoing_points + breakdown["outgoing_links_points"] = outgoing_points + breakdown["outgoing_links_count"] = actual_outgoing + + # Points per new reference + points_per_new_ref = float(evaluation.get("points_per_new_reference", 0)) + new_refs = submission_data.get("ref_new_count") or 0 + new_ref_points = round(new_refs * points_per_new_ref, 2) + score += new_ref_points + breakdown["new_references_points"] = new_ref_points + breakdown["new_references_count"] = new_refs + + # Points per reused reference + points_per_reused_ref = float(evaluation.get("points_per_reused_reference", 0)) + reused_refs = submission_data.get("ref_reused_count") or 0 + reused_ref_points = round(reused_refs * points_per_reused_ref, 2) + score += reused_ref_points + breakdown["reused_references_points"] = reused_ref_points + breakdown["reused_references_count"] = reused_refs + + # Points per infobox + points_per_infobox = float(evaluation.get("points_per_infobox", 0)) + infobox_count = submission_data.get("infobox_count") or 0 + infobox_points = round(infobox_count * points_per_infobox, 2) + score += infobox_points + breakdown["infobox_points"] = infobox_points + breakdown["infobox_count"] = infobox_count + + # Points per image + points_per_image = float(evaluation.get("points_per_image", 0)) + image_count = submission_data.get("image_count") or 0 + image_points = round(image_count * points_per_image, 2) + score += image_points + breakdown["image_points"] = image_points + breakdown["image_count"] = image_count + + # Round score to 2 decimal places + final_score = round(score, 2) + + return True, final_score, f"Eligible. Score: {final_score}", breakdown + # ------------------------------------------------------------------------ # SERIALIZATION # ------------------------------------------------------------------------ diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py index 83c00a32..a8ac49ab 100644 --- a/backend/app/models/submission.py +++ b/backend/app/models/submission.py @@ -108,6 +108,14 @@ class Submission(BaseModel): # Example: {"Quality": 8, "Sources": 7, "Neutrality": 9, "Formatting": 6} parameter_scores = db.Column(db.Text, nullable=True) + # Automated evaluation details (for automated scoring contests) + # Reason for rejection (if status is rejected) or success message + evaluation_reason = db.Column(db.Text, nullable=True) + + # Score breakdown as JSON (for accepted submissions in automated contests) + # Example: {"base_points": 10, "bytes_points": 5.2, "links_points": 3, ...} + score_breakdown = db.Column(db.Text, nullable=True) + # ------------------------------------------------------------------------ # Database Columns - Review Metadata @@ -351,6 +359,20 @@ def get_parameter_scores(self): except json.JSONDecodeError: return None + def get_score_breakdown(self): + """ + Get score breakdown for automated scoring + + Returns: + dict or None: Score breakdown with points per category + """ + if not self.score_breakdown: + return None + try: + return json.loads(self.score_breakdown) + except json.JSONDecodeError: + return None + # ------------------------------------------------------------------------ # SUBMISSION STATUS UPDATE @@ -583,6 +605,10 @@ def to_dict(self, include_user_info=False): # Multi-parameter scoring data "parameter_scores": self.get_parameter_scores(), + + # Automated evaluation details + "evaluation_reason": self.evaluation_reason, + "score_breakdown": self.get_score_breakdown(), } # Optionally include related user and contest information diff --git a/backend/app/routes/submission_routes.py b/backend/app/routes/submission_routes.py index 4b03b5c6..2f15567f 100644 --- a/backend/app/routes/submission_routes.py +++ b/backend/app/routes/submission_routes.py @@ -3,6 +3,7 @@ Handles submission management and review functionality """ +import json from urllib.parse import urlparse from datetime import datetime @@ -255,6 +256,9 @@ def refresh_metadata(contest_id): This endpoint fetches the latest metadata from MediaWiki API for all submissions in the specified contest and updates the database with the current values. + For automated scoring contests, this also evaluates each submission and updates + status (accepted/rejected) and score based on the automated criteria. + Args: contest_id: The ID of the contest to refresh submissions for @@ -264,15 +268,30 @@ def refresh_metadata(contest_id): user = request.current_user # Validate contest access and permissions - # Note: contest variable is validated but not used in this route - _contest, error_response = validate_contest_submission_access( + contest, error_response = validate_contest_submission_access( contest_id, user, Contest ) if error_response: return error_response + # Check if this is an automated scoring contest + is_automated = False + try: + is_automated = contest.get_scoring_mode() == "automated" + current_app.logger.info(f"Contest {contest_id} scoring mode: {contest.get_scoring_mode()}, is_automated: {is_automated}") + except Exception as e: + current_app.logger.warning(f"Failed to check scoring mode: {str(e)}") + # Get all submissions for this contest - submissions = Submission.query.filter_by(contest_id=contest_id).all() + # For automated contests, limit batch size to prevent timeout + # Each submission requires multiple API calls which can be slow + query = Submission.query.filter_by(contest_id=contest_id) + + # Limit to 50 submissions per refresh for automated contests to prevent timeout + if is_automated: + query = query.limit(50) + + submissions = query.all() if not submissions: return ( @@ -448,8 +467,12 @@ def calculate_expansion_bytes(submission_item, article_info): submission.article_created_at = timestamp_str else: 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 + + # For crawler-imported submissions (no article_word_count), fetch it from API + # This is needed for automated evaluation + if not submission.article_word_count and info.get("current_size"): + submission.article_word_count = info["current_size"] + if info.get("article_page_id"): submission.article_page_id = info["article_page_id"] @@ -476,6 +499,77 @@ def calculate_expansion_bytes(submission_item, article_info): except Exception: # pylint: disable=broad-exception-caught pass + # --- Automated Scoring Evaluation --- + # For automated contests, fetch additional metrics and evaluate + if is_automated: + current_app.logger.info(f"Evaluating submission {submission.id} in automated mode") + try: + # Fetch incoming/outgoing links + from app.utils import ( + get_article_incoming_links, + get_article_outgoing_links, + get_article_image_count, + get_article_infobox_count, + ) + + incoming = get_article_incoming_links(submission.article_link) or 0 + outgoing = get_article_outgoing_links(submission.article_link) or 0 + images = get_article_image_count(submission.article_link) or 0 + infoboxes = get_article_infobox_count(submission.article_link) or 0 + + current_app.logger.info(f"Submission {submission.id}: incoming={incoming}, outgoing={outgoing}, images={images}, infoboxes={infoboxes}") + + # Update submission with link counts + submission.incoming_links = incoming + submission.outgoing_links = outgoing + submission.image_count = images + submission.infobox_count = infoboxes + + # Evaluate submission against automated criteria + submission_data = { + "article_word_count": submission.article_word_count, + "incoming_links": incoming, + "outgoing_links": outgoing, + "ref_new_count": submission.ref_new_count, + "ref_reused_count": submission.ref_reused_count, + "image_count": images, + "infobox_count": infoboxes, + } + + current_app.logger.info(f"Submission {submission.id} data for evaluation: {submission_data}") + + is_eligible, final_score, reason, breakdown = contest.evaluate_automated_submission(submission_data) + + current_app.logger.info(f"Submission {submission.id} result: eligible={is_eligible}, score={final_score}, reason={reason}") + + # Update submission status, score, and evaluation details + if is_eligible: + submission.status = "accepted" + submission.score = int(round(final_score)) + submission.evaluation_reason = reason + submission.score_breakdown = json.dumps(breakdown) if breakdown else None + else: + submission.status = "rejected" + submission.score = 0 + submission.evaluation_reason = reason + submission.score_breakdown = None + + current_app.logger.info( + "Automated evaluation for submission %s: %s - %s", + submission.id, + submission.status, + reason, + ) + + except Exception as eval_error: # pylint: disable=broad-exception-caught + current_app.logger.error( + "Failed to evaluate submission %s: %s", + submission.id, + str(eval_error), + ) + import traceback + current_app.logger.error(traceback.format_exc()) + updated += 1 else: failed += 1 diff --git a/frontend/src/views/ContestView.vue b/frontend/src/views/ContestView.vue index d5ccfd9b..43680855 100644 --- a/frontend/src/views/ContestView.vue +++ b/frontend/src/views/ContestView.vue @@ -366,14 +366,25 @@ class="mt-2 pt-2" {{ submission.username || 'Unknown' }} - + {{ submission.status }}
Reviewed
- {{ submission.score || 0 }} + + + {{ submission.score || 0 }} + + {{ formatDate(submission.submitted_at) }} +
+ + + + + + @@ -1741,6 +1832,119 @@ class="btn btn-primary" + + + @@ -545,89 +303,6 @@ export default { font-size: 0.85em; } -/* Statistics Cards - Matching ContestView card style */ -.stat-card { - background: white; - border: 1px solid var(--wiki-border); - border-radius: 8px; - padding: 1.25rem; - display: flex; - align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; - height: 100%; -} - -/* Dark mode stat card styling */ -[data-theme="dark"] .stat-card { - background-color: #2a2a2a; - border-color: #444; -} - -/* Hover effect for stat cards */ -.stat-card:hover { - box-shadow: 0 4px 8px rgba(0, 102, 153, 0.15); - transform: translateY(-2px); -} - -.stat-icon-wrapper { - margin-right: 1rem; -} - -/* Colored icon containers for different stat types */ -.stat-icon { - width: 50px; - height: 50px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.3rem; - color: white; -} - -.stat-icon.bg-primary { - background-color: var(--wiki-primary); -} - -.stat-icon.bg-success { - background-color: #28a745; -} - -.stat-icon.bg-warning { - background-color: #ffc107; - color: #000; -} - -.stat-icon.bg-info { - background-color: #17a2b8; -} - -.stat-content { - flex: 1; -} - -/* Large number display for stat values */ -.stat-value { - font-size: 1.75rem; - font-weight: 700; - color: var(--wiki-dark); - line-height: 1; - margin-bottom: 0.25rem; -} - -/* Dark mode stat value color */ -[data-theme="dark"] .stat-value { - color: #ffffff; -} - -.stat-label { - color: var(--wiki-text-muted); - font-size: 0.85rem; - font-weight: 500; -} - -/* Card Styling - Matching ContestView cards */ .card { border: 1px solid var(--wiki-border); border-radius: 8px; @@ -635,6 +310,7 @@ export default { transition: box-shadow 0.2s ease; } + /* Dark mode card styling */ [data-theme="dark"] .card { background-color: #2a2a2a; @@ -659,51 +335,6 @@ export default { padding: 1.5rem; } -/* Filters Card */ -.filters-card .card-body { - padding: 1.25rem 1.5rem; -} - -.form-label { - font-weight: 600; - color: var(--wiki-dark); - margin-bottom: 0.5rem; - font-size: 0.9rem; -} - -/* Dark mode form label */ -[data-theme="dark"] .form-label { - color: #ffffff; -} - -/* Form input styling */ -.form-control, -.form-select { - border: 1px solid var(--wiki-border); - border-radius: 8px; - padding: 0.5rem 0.75rem; - transition: all 0.2s ease; - background-color: white; - color: var(--wiki-dark); -} - -/* Dark mode form inputs */ -[data-theme="dark"] .form-control, -[data-theme="dark"] .form-select { - background-color: #2a2a2a; - color: #ffffff; - border-color: #444; -} - -/* Focus state with brand color */ -.form-control:focus, -.form-select:focus { - border-color: var(--wiki-primary); - box-shadow: 0 0 0 0.2rem rgba(0, 102, 153, 0.25); - outline: none; -} - -/* Empty State */ .empty-state-card .card-body { padding: 3rem 1.5rem; } @@ -741,6 +372,7 @@ export default { white-space: nowrap; } + /* Dark mode table header */ [data-theme="dark"] .leaderboard-table thead th { background-color: rgba(93, 184, 230, 0.15); @@ -768,90 +400,6 @@ export default { color: var(--wiki-text); } -/* Top 3 Row Highlights */ -/* Gold highlight for 1st place */ -.rank-row-1 { - background-color: rgba(255, 215, 0, 0.08) !important; -} - -/* Silver highlight for 2nd place */ -.rank-row-2 { - background-color: rgba(192, 192, 192, 0.08) !important; -} - -/* Bronze highlight for 3rd place */ -.rank-row-3 { - background-color: rgba(205, 127, 50, 0.08) !important; -} - -/* Dark mode hover for top 3 rows */ -[data-theme="dark"] .rank-row-1:hover, -[data-theme="dark"] .rank-row-2:hover, -[data-theme="dark"] .rank-row-3:hover { - background-color: rgba(93, 184, 230, 0.1) !important; -} - -/* Rank Badge */ -.rank-col { - width: 70px; - text-align: center; -} - -/* Circular badge for rank display */ -.rank-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 45px; - height: 45px; - border-radius: 50%; - font-weight: 700; - font-size: 1.1rem; -} - -/* Gold gradient for 1st place */ -.rank-badge-gold { - background: linear-gradient(135deg, #ffd700, #ffed4e); - color: #000; - box-shadow: 0 2px 6px rgba(255, 215, 0, 0.4); -} - -/* Silver gradient for 2nd place */ -.rank-badge-silver { - background: linear-gradient(135deg, #c0c0c0, #e8e8e8); - color: #444; - box-shadow: 0 2px 6px rgba(192, 192, 192, 0.4); -} - -/* Bronze gradient for 3rd place */ -.rank-badge-bronze { - background: linear-gradient(135deg, #cd7f32, #e89c5f); - color: #fff; - box-shadow: 0 2px 6px rgba(205, 127, 50, 0.4); -} - -/* Default badge for ranks 4+ */ -.rank-badge-default { - background-color: #f8f9fa; - color: var(--wiki-dark); - border: 2px solid #dee2e6; -} - -/* Dark mode default rank badge */ -[data-theme="dark"] .rank-badge-default { - background-color: #3a3a3a; - color: #ffffff; - border-color: #555; -} - -.rank-icon { - font-size: 1.3rem; -} - -.rank-number { - font-size: 1rem; -} - /* Username Cell */ .username-cell { display: flex; @@ -987,28 +535,8 @@ export default { font-size: 1.5rem; } - .stat-card { - padding: 1rem; - } - - .stat-icon { - width: 50px; - height: 50px; - font-size: 1.2rem; - } - - .stat-content h3 { - font-size: 1.5rem; - } - .leaderboard-table { font-size: 0.9rem; } - - .rank-badge { - width: 40px; - height: 40px; - font-size: 1rem; - } } - + \ No newline at end of file diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue index 58d7fa87..41c09e4c 100644 --- a/frontend/src/views/Profile.vue +++ b/frontend/src/views/Profile.vue @@ -59,7 +59,7 @@
- + Trusted Member : From 334a353ead5f301f5519f8a6ec74f2ddf431796a Mon Sep 17 00:00:00 2001 From: Gauri Date: Fri, 17 Apr 2026 01:41:09 +0530 Subject: [PATCH 18/23] feat: dashboard UI updates and some more frontend chnages --- CRITICAL_ISSUES_REPORT.md | 12 +- backend/app/routes/user_routes.py | 34 +- frontend/src/components/ContestModal.vue | 4 +- frontend/src/components/JuryDashboard.vue | 172 +-- frontend/src/components/JuryFeedbackModal.vue | 41 +- frontend/src/views/ContestView.vue | 1324 +++++++---------- frontend/src/views/Dashboard.vue | 739 ++++++--- 7 files changed, 1105 insertions(+), 1221 deletions(-) diff --git a/CRITICAL_ISSUES_REPORT.md b/CRITICAL_ISSUES_REPORT.md index 78af4bcb..ee3b276f 100644 --- a/CRITICAL_ISSUES_REPORT.md +++ b/CRITICAL_ISSUES_REPORT.md @@ -270,15 +270,15 @@ def update_contest(contest_id): ### Immediate (Before Any Production Deployment) -1. ✅ **Fix hardcoded secrets** - Remove `'rohank10'` defaults -2. ✅ **Secure debug endpoint** - Add authentication or remove -3. ✅ **Revoke OAuth credentials** - Generate new ones, move to env vars -4. ✅ **Disable debug mode** - Use environment variable +1. **Fix hardcoded secrets** - Remove `'rohank10'` defaults +2. **Secure debug endpoint** - Add authentication or remove +3. **Revoke OAuth credentials** - Generate new ones, move to env vars +4. **Disable debug mode** - Use environment variable ### High Priority (Before Next Release) -5. ✅ **Fix database password default** - Require DATABASE_URL -6. ✅ **Add error handler** - Fix update_contest route +5. **Fix database password default** - Require DATABASE_URL +6. **Add error handler** - Fix update_contest route ### Additional Recommendations diff --git a/backend/app/routes/user_routes.py b/backend/app/routes/user_routes.py index d0fddb6c..c99dd58d 100644 --- a/backend/app/routes/user_routes.py +++ b/backend/app/routes/user_routes.py @@ -254,15 +254,14 @@ def get_dashboard(): JSON response with user's dashboard information """ user = request.current_user - # Get user's total score total_score = user.score + # --- Get Contest-wise Scores --- - # --- Get Contest-wise Scores --- from app.models.submission import Submission from app.models.contest import Contest - # Query submissions grouped by contest to calculate scores + # Contest-wise scores contest_scores = db.session.query( Contest.id.label('contest_id'), Contest.name.label('contest_name'), @@ -272,7 +271,7 @@ def get_dashboard(): Submission.user_id == user.id ).group_by(Contest.id, Contest.name).order_by(Contest.name).all() - # --- Get All User's Submissions Grouped by Contest --- + # Submissions grouped by contest submissions_query = db.session.query( Submission, Contest.name.label('contest_name') @@ -280,7 +279,6 @@ def get_dashboard(): Submission.user_id == user.id ).order_by(Submission.submitted_at.desc()).all() - # Group submissions by contest for organized display submissions_by_contest = {} for submission, contest_name in submissions_query: contest_id = submission.contest_id @@ -292,7 +290,7 @@ def get_dashboard(): } submissions_by_contest[contest_id]['submissions'].append(submission.to_dict()) - # --- Get Contests Created by User --- + # Created contests created_contests = Contest.query.filter_by(created_by=user.username).all() created_contests_data = [] for contest in created_contests: @@ -300,7 +298,7 @@ def get_dashboard(): contest_data['submission_count'] = contest.get_submission_count() created_contests_data.append(contest_data) - # --- Get Contests Where User is a Jury Member --- + # Jury contests jury_contests = Contest.query.filter( Contest.jury_members.like(f'%{user.username}%') ).all() @@ -310,6 +308,24 @@ def get_dashboard(): contest_data['submission_count'] = contest.get_submission_count() jury_contests_data.append(contest_data) + # Participated contests (contests jisme user ne submit kiya) + participated_contests_query = db.session.query( + Contest, + db.func.min(Submission.submitted_at).label('submitted_at') + ).join( + Submission, Contest.id == Submission.contest_id + ).filter( + Submission.user_id == user.id + ).group_by(Contest.id).order_by( + db.func.min(Submission.submitted_at).desc() + ).all() + + participated_contests_data = [] + for contest, submitted_at in participated_contests_query: + contest_data = contest.to_dict() + contest_data['submitted_at'] = submitted_at.isoformat() if submitted_at else None + participated_contests_data.append(contest_data) + return jsonify({ 'username': user.username, 'total_score': total_score, @@ -324,10 +340,10 @@ def get_dashboard(): ], 'submissions_by_contest': list(submissions_by_contest.values()), 'created_contests': created_contests_data, - 'jury_contests': jury_contests_data + 'jury_contests': jury_contests_data, + 'participated_contests': participated_contests_data # NEW }), 200 - @user_bp.route('/all', methods=['GET']) @require_role('admin') @handle_errors diff --git a/frontend/src/components/ContestModal.vue b/frontend/src/components/ContestModal.vue index c16c6e54..e48f170c 100644 --- a/frontend/src/components/ContestModal.vue +++ b/frontend/src/components/ContestModal.vue @@ -802,7 +802,7 @@ export default { // If we got a result, break if (canDeleteContest.value) { - console.log('✅ Delete permission granted!') + console.log('Delete permission granted!') break } } @@ -825,7 +825,7 @@ export default { console.error('Match:', (finalUser.username || '').toLowerCase() === (newContest.created_by || '').toLowerCase()) } } else { - console.log('✅ Delete permission check successful!') + console.log('Delete permission check successful!') } } catch (error) { console.error('❌ Failed to check auth:', error) diff --git a/frontend/src/components/JuryDashboard.vue b/frontend/src/components/JuryDashboard.vue index 8847a8f0..eb6e058e 100644 --- a/frontend/src/components/JuryDashboard.vue +++ b/frontend/src/components/JuryDashboard.vue @@ -1,42 +1,6 @@