Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
70 changes: 36 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,40 @@ 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 (ValueError, TypeError):
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_val = day_match.group(1)
if len(end_str) <= max_day_length or "," in end_str:
return start_date.replace(day=int(day_val))

default_dt = datetime.combine(start_date, datetime.min.time())
end_date = parser.parse(end_str, default=default_dt).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 (ValueError, TypeError, OverflowError, IndexError):
return None

@staticmethod
def update_data(category, data, *, save: bool = True) -> Event | None:
Expand All @@ -174,7 +176,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
84 changes: 21 additions & 63 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"),
[
("June 5, 2025", date(2025, 6, 5), date(2025, 6, 5)),
("2025-05-26", date(2025, 5, 26), date(2025, 5, 26)),
("May 26-30", date(2025, 5, 26), date(2025, 5, 30)),
("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 30 - June 2, 2025", date(2025, 5, 30), date(2025, 6, 2)),
("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)),
("Dec 30, 2025 - Jan 2, 2026", date(2025, 12, 30), date(2026, 1, 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 @@ -177,12 +140,7 @@ def test_from_dict(self):
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 = {
"name": "Example Event",
"start-date": date(2025, 1, 1),
"dates": "",
}

data = {"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
5 changes: 4 additions & 1 deletion frontend/src/components/CardDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ const DetailsCard = ({
userSummary,
}: DetailsCardProps) => {
const { data: session } = useSession() as { data: ExtendedSession | null }
const hasContributions =
!!contributionStats ||
(contributionData != null && Object.keys(contributionData).length > 0)

// compute styles based on type prop
const typeStylesMap = {
Expand Down Expand Up @@ -260,7 +263,7 @@ const DetailsCard = ({
</>
)}
{entityLeaders && entityLeaders.length > 0 && <Leaders users={entityLeaders} />}
{(type === 'project' || type === 'chapter') && (contributionData || contributionStats) && (
{(type === 'project' || type === 'chapter') && hasContributions && (
<div className="mb-8">
<div className="rounded-lg bg-gray-100 px-4 pt-6 shadow-md sm:px-6 lg:px-10 dark:bg-gray-800">
{contributionStats && (
Expand Down