Skip to content

Commit 07466ef

Browse files
Backend: Normalize approved-config lock field comparison. (#5085)
Compare challenge phase dates and descriptions semantically so YAML configs keep human-readable date formats after admin approval.
1 parent 03d6547 commit 07466ef

2 files changed

Lines changed: 97 additions & 3 deletions

File tree

apps/challenges/challenge_config_utils.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import re
44
import zipfile
5+
from datetime import datetime
56
from os.path import basename, isfile, join
67

78
import requests
@@ -14,6 +15,7 @@
1415
Leaderboard,
1516
)
1617
from django.core.files.base import ContentFile
18+
from django.utils import dateparse, timezone
1719
from rest_framework import status
1820
from yaml.scanner import ScannerError
1921

@@ -58,6 +60,33 @@
5860
"is_partial_submission_evaluation_enabled",
5961
)
6062

63+
_CHALLENGE_PHASE_DATE_LOCK_FIELDS = frozenset({"start_date", "end_date"})
64+
65+
66+
def _parse_lock_datetime(value):
67+
if value is None:
68+
return None
69+
if isinstance(value, datetime):
70+
dt = value
71+
elif isinstance(value, str):
72+
stripped = value.strip()
73+
dt = dateparse.parse_datetime(stripped)
74+
if dt is None:
75+
dt = dateparse.parse_datetime(stripped.replace(" ", "T", 1))
76+
else:
77+
return value
78+
if timezone.is_naive(dt):
79+
dt = timezone.make_aware(dt, timezone.utc)
80+
return dt.astimezone(timezone.utc).replace(microsecond=0)
81+
82+
83+
def _normalize_lock_field_value(field, value):
84+
if field in _CHALLENGE_PHASE_DATE_LOCK_FIELDS:
85+
return _parse_lock_datetime(value)
86+
if field == "description" and isinstance(value, str):
87+
return value.rstrip()
88+
return value
89+
6190

6291
def write_file(output_path, mode, file_content):
6392
with open(output_path, mode) as file:
@@ -462,6 +491,13 @@ def _approved_config_locked(self):
462491
def _stable_json(value):
463492
return json.dumps(value, sort_keys=True, default=str)
464493

494+
def _lock_field_values_equal(self, field, db_value, yaml_value):
495+
norm_db = _normalize_lock_field_value(field, db_value)
496+
norm_yaml = _normalize_lock_field_value(field, yaml_value)
497+
if isinstance(norm_db, datetime) and isinstance(norm_yaml, datetime):
498+
return norm_db == norm_yaml
499+
return self._stable_json(norm_db) == self._stable_json(norm_yaml)
500+
465501
def _locked_leaderboard_modified_message(self, data):
466502
lb = Leaderboard.objects.filter(
467503
config_id=int(data["id"]),
@@ -505,8 +541,8 @@ def _locked_challenge_phase_modified_message(self, data):
505541
for field in _CHALLENGE_PHASE_APPROVED_LOCK_FIELDS:
506542
if field not in data:
507543
continue
508-
if self._stable_json(getattr(phase, field)) != self._stable_json(
509-
data.get(field)
544+
if not self._lock_field_values_equal(
545+
field, getattr(phase, field), data.get(field)
510546
):
511547
return self.error_messages_dict[
512548
"locked_challenge_phase_modified"

tests/unit/challenges/test_challenge_config_utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import unittest
55
import uuid
66
import zipfile
7-
from datetime import timedelta
7+
from datetime import datetime, timedelta
88
from os.path import basename, join
99
from unittest.mock import Mock
1010
from unittest.mock import patch as mockpatch
@@ -1622,6 +1622,64 @@ def test_locked_challenge_phase_none_when_consistent(self):
16221622
)
16231623
self.assertIsNone(msg)
16241624

1625+
def test_locked_challenge_phase_accepts_yaml_date_format(self):
1626+
phase_start = timezone.make_aware(
1627+
datetime(2019, 1, 19, 0, 0, 0), timezone.utc
1628+
)
1629+
phase_end = timezone.make_aware(
1630+
datetime(2019, 1, 20, 0, 0, 0), timezone.utc
1631+
)
1632+
self.phase.start_date = phase_start
1633+
self.phase.end_date = phase_end
1634+
self.phase.save()
1635+
1636+
msg = self.util._locked_challenge_phase_modified_message(
1637+
{
1638+
"id": 1,
1639+
"name": "Phase 1",
1640+
"start_date": "2019-01-19 00:00:00",
1641+
"end_date": "2019-01-20 00:00:00",
1642+
"test_annotation_file": self.phase_annotation_basename,
1643+
}
1644+
)
1645+
self.assertIsNone(msg)
1646+
1647+
def test_locked_challenge_phase_accepts_iso_date_format(self):
1648+
phase_start = timezone.make_aware(
1649+
datetime(2019, 1, 19, 0, 0, 0), timezone.utc
1650+
)
1651+
phase_end = timezone.make_aware(
1652+
datetime(2019, 1, 20, 0, 0, 0), timezone.utc
1653+
)
1654+
self.phase.start_date = phase_start
1655+
self.phase.end_date = phase_end
1656+
self.phase.save()
1657+
1658+
msg = self.util._locked_challenge_phase_modified_message(
1659+
{
1660+
"id": 1,
1661+
"name": "Phase 1",
1662+
"start_date": "2019-01-19T00:00:00Z",
1663+
"end_date": "2019-01-20T00:00:00Z",
1664+
"test_annotation_file": self.phase_annotation_basename,
1665+
}
1666+
)
1667+
self.assertIsNone(msg)
1668+
1669+
def test_locked_challenge_phase_accepts_description_trailing_newline(self):
1670+
self.phase.description = "<p>hello</p>"
1671+
self.phase.save()
1672+
1673+
msg = self.util._locked_challenge_phase_modified_message(
1674+
{
1675+
"id": 1,
1676+
"name": "Phase 1",
1677+
"description": "<p>hello</p>\n",
1678+
"test_annotation_file": self.phase_annotation_basename,
1679+
}
1680+
)
1681+
self.assertIsNone(msg)
1682+
16251683
def test_locked_challenge_phase_detects_annotation_reference_change(self):
16261684
msg = self.util._locked_challenge_phase_modified_message(
16271685
{

0 commit comments

Comments
 (0)