diff --git a/agenda/add_new_conference.py b/agenda/add_new_conference.py index 1a6820f..e74455c 100644 --- a/agenda/add_new_conference.py +++ b/agenda/add_new_conference.py @@ -502,6 +502,42 @@ def parse_yaml_datetime(value: typing.Any) -> datetime | None: return None +def parse_yaml_date_value(value: typing.Any) -> date | datetime | None: + """Convert YAML date/datetime strings to date-like values.""" + if isinstance(value, datetime): + return value + + if isinstance(value, date): + return value + + if not isinstance(value, str): + return None + + try: + if " " in value or "T" in value: + return datetime.fromisoformat(value) + return date.fromisoformat(value) + except ValueError: + return None + + +def normalize_date_values(conf: dict[str, typing.Any]) -> None: + """Normalize quoted ISO date/datetime values produced by the LLM.""" + dates = conf.get("dates") + if isinstance(dates, dict): + for field in ("start", "end", "earliest", "latest"): + if field in dates: + parsed = parse_yaml_date_value(dates[field]) + if parsed is not None: + dates[field] = parsed + + for field in ("start", "end"): + if field in conf: + parsed = parse_yaml_date_value(conf[field]) + if parsed is not None: + conf[field] = parsed + + def same_type_as_start( start_value: typing.Any, new_dt: datetime, @@ -529,6 +565,7 @@ def same_type_as_start( def normalize_dates_field(conf: dict[str, typing.Any]) -> None: """Move legacy top-level date fields into the nested dates mapping.""" + normalize_date_values(conf) raw_dates = conf.get("dates") dates = raw_dates if isinstance(raw_dates, dict) else None @@ -550,6 +587,20 @@ def normalize_dates_field(conf: dict[str, typing.Any]) -> None: conf.pop("start", None) conf.pop("end", None) conf.pop("date_status", None) + normalize_date_values(conf) + + +def validate_generated_conference(conf: dict[str, typing.Any]) -> None: + """Validate generated conference YAML before inserting it.""" + try: + conference_date_fields(conf) + except ValueError as exc: + generated_yaml = yaml.dump(conf, sort_keys=False, allow_unicode=True).strip() + raise ValueError( + "Generated conference YAML is missing valid date information. " + "Expected nested `dates:` with exact/tentative start/end or " + f"approximate earliest/latest.\n\nGenerated YAML:\n{generated_yaml}" + ) from exc def maybe_extract_explicit_end_time(source_text: str) -> int | None: @@ -661,6 +712,7 @@ def add_new_conference(url: str, yaml_path: str) -> bool: normalize_dates_field(new_conf) normalise_end_field(new_conf, source_text) normalize_dates_field(new_conf) + validate_generated_conference(new_conf) if detected_coordinates is not None: new_conf["latitude"] = detected_coordinates[0] diff --git a/templates/conference_series_list.html b/templates/conference_series_list.html index a39890d..f9ccc5f 100644 --- a/templates/conference_series_list.html +++ b/templates/conference_series_list.html @@ -21,6 +21,9 @@ {{ item.name }} + {% if item.attended %} + attended + {% endif %} {% if item.url %} {% endif %} diff --git a/tests/test_add_new_conference.py b/tests/test_add_new_conference.py index 34f80d0..02d19df 100644 --- a/tests/test_add_new_conference.py +++ b/tests/test_add_new_conference.py @@ -145,6 +145,35 @@ def test_normalize_dates_field_moves_legacy_dates() -> None: } +def test_normalize_dates_field_parses_quoted_dates() -> None: + """Quoted ISO dates from generated YAML should become date objects.""" + conf: dict[str, typing.Any] = { + "name": "Git Merge", + "dates": { + "status": "exact", + "start": "2026-09-16", + "end": "2026-09-17", + }, + } + + add_new_conference.normalize_dates_field(conf) + + assert conf["dates"]["start"] == date(2026, 9, 16) + assert conf["dates"]["end"] == date(2026, 9, 17) + + +def test_validate_generated_conference_reports_missing_dates() -> None: + """Missing generated dates should raise a clear importer error.""" + conf: dict[str, typing.Any] = { + "name": "Git Merge", + "topic": "Git", + "location": "TBC", + } + + with pytest.raises(ValueError, match="missing valid date information"): + add_new_conference.validate_generated_conference(conf) + + def test_build_prompt_includes_nested_dates_and_series() -> None: """The prompt should describe nested dates and known series IDs.""" prompt = add_new_conference.build_prompt( diff --git a/tests/test_conference_list.py b/tests/test_conference_list.py index bd68c0d..6eb26f7 100644 --- a/tests/test_conference_list.py +++ b/tests/test_conference_list.py @@ -118,5 +118,6 @@ def test_conference_series_pages(tmp_path: typing.Any, monkeypatch: typing.Any) assert index_response.status_code == 200 assert b"PyCascades" in index_response.data + assert b"attended" in index_response.data assert detail_response.status_code == 200 assert b"trip: Seattle Python trip" in detail_response.data diff --git a/web_view.py b/web_view.py index 534fb2b..de824e0 100755 --- a/web_view.py +++ b/web_view.py @@ -413,12 +413,14 @@ def build_conference_series_list() -> list[StrDict]: next_conf = next( (conf for conf in linked if conf["latest_date"] >= date.today()), None ) + attended = any(conf.get("going") or conf.get("linked_trip") for conf in linked) item: StrDict = { "id": series_id, **series, "count": len(linked), "latest": latest, "next_conf": next_conf, + "attended": attended, } series_items.append(item)