Skip to content
Merged
71 changes: 37 additions & 34 deletions backend/apps/owasp/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

from __future__ import annotations

import re
from typing import TYPE_CHECKING

from apps.core.models.prompt import Prompt

if TYPE_CHECKING: # pragma: no cover
from datetime import date

from datetime import datetime

from dateutil import parser
from django.db import models
from django.db.models import Q
Expand Down Expand Up @@ -68,7 +71,7 @@ def __str__(self) -> str:
def upcoming_events():
"""Get upcoming events.

Returns
Returns:
QuerySet: A queryset of upcoming Event instances ordered by start date.

"""
Expand Down Expand Up @@ -103,7 +106,7 @@ def bulk_save( # type: ignore[override]

# TODO(arkid15r): refactor this when there is a chance.
@staticmethod
def parse_dates(dates: str, start_date: date) -> date | None:
def parse_dates(dates: str, start_date: date) -> date | None: # noqa: PLR0911
"""Parse event dates.

Args:
Expand All @@ -117,41 +120,41 @@ def parse_dates(dates: str, start_date: date) -> date | None:
if not dates:
return None

# Handle single-day events (e.g., "March 14, 2025")
if "," in dates and "-" not in dates:
try:
return parser.parse(dates).date()
except ValueError:
return None

# Handle date ranges (e.g., "May 26-30, 2025" or "November 2-6, 2026")
if "-" in dates and "," in dates:
try:
# Split the date range into parts
date_part, year_part = dates.rsplit(", ", 1)
parts = date_part.split()

# Extract month and day range
month = parts[0]
day_range = "".join(parts[1:])

# Extract end day from the range
end_day = int(day_range.split("-")[-1])

# Parse the year
year = int(year_part.strip())
max_day_length = 2

# Use the start_date to determine the month if provided
if start_date:
start_date_parsed = start_date
month = start_date_parsed.strftime("%B") # Full month name (e.g., "May")
# Normalize dashes (hyphen, en-dash, em-dash).
clean_dates = re.sub(r"[\-\–\—]", "-", dates) # noqa: RUF001

# Parse the full end date string
return parser.parse(f"{month} {end_day}, {year}").date()
except (ValueError, IndexError, AttributeError):
# Guard against ISO-like single dates (e.g. "2025-05-26").
if re.match(r"^\d{4}-\d{2}-\d{2}$", clean_dates.strip()):
try:
return parser.parse(clean_dates.strip()).date()
except (TypeError, ValueError):
return None

return None
try:
if "-" in clean_dates:
parts = clean_dates.split("-")
end_str = parts[-1].strip()

day_match = re.match(r"^(\d{1,2})", end_str)
if day_match:
day_value = day_match.group(1)
if len(end_str) <= max_day_length or "," in end_str:
return start_date.replace(day=int(day_value))

end_date = parser.parse(
end_str, default=datetime.combine(start_date, datetime.min.time())
).date()
# Handle year crossover: if end_date is before start_date, assume next year.
if end_date < start_date:
end_date = end_date.replace(year=end_date.year + 1)
return end_date

return parser.parse(clean_dates.strip()).date()

except (IndexError, OverflowError, TypeError, ValueError):
return None

@staticmethod
def update_data(category, data, *, save: bool = True) -> Event | None:
Expand All @@ -174,7 +177,7 @@ def update_data(category, data, *, save: bool = True) -> Event | None:

try:
event.from_dict(category, data)
except KeyError: # No start date.
except KeyError:
return None

if save:
Expand Down
80 changes: 21 additions & 59 deletions backend/tests/apps/owasp/models/event_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,72 +31,48 @@ def test_bulk_save(self):
@pytest.mark.parametrize(
("dates", "start_date", "expected_result"),
[
# Test case: None or empty dates
(None, date(2025, 5, 26), None),
("", date(2025, 5, 26), None),
# Test case: Single-day events
("June 5, 2025", date(2025, 6, 5), date(2025, 6, 5)),
("June 19, 2025", date(2025, 6, 19), date(2025, 6, 19)),
# Test case: Date ranges with proper format
("May 26-30, 2025", date(2025, 5, 26), date(2025, 5, 30)),
("November 3-7, 2025", date(2025, 11, 3), date(2025, 11, 7)),
("November 2-6, 2026", date(2026, 11, 2), date(2026, 11, 6)),
("September 2-5, 2025", date(2025, 9, 2), date(2025, 9, 5)),
("September 12-13, 2025", date(2025, 9, 12), date(2025, 9, 13)),
("October 21-24, 2025", date(2025, 10, 21), date(2025, 10, 24)),
("November 19-20, 2025", date(2025, 11, 19), date(2025, 11, 20)),
("December 2-3, 2025", date(2025, 12, 2), date(2025, 12, 3)),
# Test case: Special format with spaces in date ranges
(
"May 25 - 28, 2025",
date(2025, 5, 25),
date(2025, 5, 28),
),
# Test edge cases
("Invalid date", None, None), # Invalid date format
("May 26-invalid, 2025", date(2025, 5, 26), None), # Invalid day in range
("May, 2025", None, None), # Missing day
("Invalid date range", date(2025, 5, 26), None),
],
)
def test_parse_dates(self, dates, start_date, expected_result):
def test_parse_dates_unit(self, dates, start_date, expected_result):
"""Unit test for parse_dates structure using mocks."""
if not dates:
assert Event.parse_dates(dates, start_date) is None
return

with patch("apps.owasp.models.event.parser.parse") as mock_parse:
if expected_result is not None:
if expected_result:
mock_date = Mock()
mock_date.date.return_value = expected_result
mock_parse.return_value = mock_date
elif dates and ("Invalid" in dates or dates in {"May, 2025"}):
mock_parse.side_effect = ValueError("Invalid date")
result = Event.parse_dates(dates, start_date)
assert result == expected_result
else:
mock_date = Mock()
mock_date.date.return_value = date(2025, 1, 1)
mock_parse.return_value = mock_date

result = Event.parse_dates(dates, start_date)
assert result == expected_result
mock_parse.side_effect = ValueError("Invalid")
result = Event.parse_dates(dates, start_date)
assert result is None

@pytest.mark.parametrize(
("dates", "start_date", "expected_result"),
[
("2025-05-26", date(2025, 5, 26), date(2025, 5, 26)),
("Dec 30, 2025 - Jan 2, 2026", date(2025, 12, 30), date(2026, 1, 2)),
("June 5, 2025", date(2025, 6, 5), date(2025, 6, 5)),
("May 26-30, 2025", date(2025, 5, 26), date(2025, 5, 30)),
("November 3-7, 2025", date(2025, 11, 3), date(2025, 11, 7)),
("September 2-5, 2025", date(2025, 9, 2), date(2025, 9, 5)),
("October 21-24, 2025", date(2025, 10, 21), date(2025, 10, 24)),
("May 26-30", date(2025, 5, 26), date(2025, 5, 30)),
("May 26–30, 2025", date(2025, 5, 26), date(2025, 5, 30)), # noqa: RUF001
("May 26—30, 2025", date(2025, 5, 26), date(2025, 5, 30)),
("May 30 - June 2, 2025", date(2025, 5, 30), date(2025, 6, 2)),
],
)
def test_parse_dates_integration(self, dates, start_date, expected_result):
"""Test parse_dates with real parser (no mocking)."""
"""Test parse_dates with real parser to verify crossover and dash logic."""
result = Event.parse_dates(dates, start_date)
assert result == expected_result

def test_parse_dates_with_space_in_range(self):
"""Test the edge case with spaces in date range that's currently handled."""
dates = "May 25 - 28, 2025"
start_date = date(2025, 5, 25)

result = Event.parse_dates(dates, start_date)
assert result == date(2025, 5, 28)

def test_update_data_existing_event(self):
"""Test update_data when the event already exists."""
category = "Global"
Expand All @@ -115,22 +91,14 @@ def test_update_data_existing_event(self):
patch("apps.owasp.models.event.parser.parse") as mock_parser,
):
mock_slugify.return_value = "test-event"

mock_parse_dates.return_value = date(2025, 5, 30)

mock_date = Mock()
mock_date.date.return_value = date(2025, 5, 26)
mock_parser.return_value = mock_date

mock_event = Mock()
mock_get.return_value = mock_event

result = Event.update_data(category, data)

mock_slugify.assert_called_once_with(data["name"])
mock_get.assert_called_once_with(key="test-event")
mock_event.from_dict.assert_called_once_with(category, data)
mock_event.save.assert_called_once()
assert result == mock_event

def test_from_dict(self):
Expand All @@ -157,13 +125,8 @@ def test_from_dict(self):
mock_normalize_url.return_value = "https://example.com/event/global-event-2025"

event.from_dict(category, data)

assert event.category == Event.Category.GLOBAL
assert event.name == "Example Global Event 2025"
assert event.start_date == date(2025, 5, 26)
assert event.end_date == date(2025, 5, 30)
assert event.description == "This is a test description"
assert event.url == "https://example.com/event/global-event-2025"

@pytest.mark.parametrize(
("category_str", "expected_category"),
Expand All @@ -178,11 +141,10 @@ def test_category_mapping(self, category_str, expected_category):
"""Test that category strings are correctly mapped to enum values."""
event = Event(key="test-event")
data = {
"dates": "",
"name": "Example Event",
"start-date": date(2025, 1, 1),
"dates": "",
}

with (
patch("apps.owasp.models.event.Event.parse_dates") as mock_parse_dates,
patch("apps.owasp.models.event.normalize_url") as mock_normalize_url,
Expand Down