Compare commits
4 commits
098c7e4447
...
a87c9f993e
| Author | SHA1 | Date | |
|---|---|---|---|
| a87c9f993e | |||
| dbce9e5358 | |||
| 56eea3f7a3 | |||
| 57b2db205d |
12 changed files with 866 additions and 54 deletions
|
|
@ -16,7 +16,7 @@ import pycountry
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from agenda.conference import conference_date_fields
|
from agenda.conference import ConferenceSeries, conference_date_fields, load_series
|
||||||
|
|
||||||
USER_AGENT = "add-new-conference/0.1"
|
USER_AGENT = "add-new-conference/0.1"
|
||||||
COORDINATE_PATTERNS = (
|
COORDINATE_PATTERNS = (
|
||||||
|
|
@ -36,58 +36,139 @@ def read_api_key() -> str:
|
||||||
return parser["openai"]["api_key"]
|
return parser["openai"]["api_key"]
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(
|
def conference_yaml_format_description() -> str:
|
||||||
url: str,
|
"""Return the conference YAML format description for LLM prompts."""
|
||||||
source_text: str,
|
return """
|
||||||
detected_coordinates: tuple[float, float] | None,
|
Use this YAML format for one conference entry.
|
||||||
) -> str:
|
|
||||||
"""Build prompt with embedded YAML examples."""
|
Required fields:
|
||||||
examples = """
|
- `name`: event name.
|
||||||
|
- `topic`: topic/category.
|
||||||
|
- `location`: city or location label. Use `TBC` if the page confirms a future
|
||||||
|
event but not a city.
|
||||||
|
- Date information in nested `dates`.
|
||||||
|
|
||||||
|
Preferred date shape:
|
||||||
|
- `dates.status`: one of `exact`, `tentative`, or `approximate`.
|
||||||
|
- For `exact`: use when the page confirms specific dates/times. Include
|
||||||
|
`dates.start` and `dates.end` as YAML dates or timezone-aware datetimes.
|
||||||
|
- For `tentative`: use when specific dates are guessed or explicitly
|
||||||
|
unconfirmed. Include `dates.start`, `dates.end`, and preferably `dates.label`
|
||||||
|
and `dates.basis`.
|
||||||
|
- For `approximate`: use when only a broad date phrase is known. Include
|
||||||
|
`dates.label`, `dates.earliest`, and `dates.latest`. Examples: `March 2027`
|
||||||
|
should become earliest `2027-03-01`, latest `2027-03-31`; `mid-April 2027`
|
||||||
|
should become a sensible bounded range such as `2027-04-11` to `2027-04-20`.
|
||||||
|
|
||||||
|
Important date rule:
|
||||||
|
- If the source page contains exact dates, output `dates.status: exact` even if
|
||||||
|
the existing agenda entry or conference announcement previously had only
|
||||||
|
approximate dates.
|
||||||
|
- Always include an end date for `exact` and `tentative`. For a single-day
|
||||||
|
event, `dates.end` can be the same as `dates.start`.
|
||||||
|
- Do not output legacy top-level `start`, `end`, or `date_status`.
|
||||||
|
|
||||||
|
Common optional fields:
|
||||||
|
- `series`: a key from the known conference series list, when this event belongs
|
||||||
|
to a listed series.
|
||||||
|
- `country`: valid ISO 3166-1 alpha-2 country code in lowercase, for example
|
||||||
|
`ca`, `gb`, `us`. Do not output country names.
|
||||||
|
- `venue`, `address`, `latitude`, `longitude`, `url`, `cfp_url`, `cfp_end`,
|
||||||
|
`hashtag`, `description`.
|
||||||
|
- `free`, `price`, `currency`, `hackathon`, `online`, `attendees`.
|
||||||
|
- Do not include `going`, `registered`, `accommodation_booked`,
|
||||||
|
`transport_booked`, or `trip` unless the source explicitly says they apply to
|
||||||
|
my attendance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_example_text() -> str:
|
||||||
|
"""Return examples of the conference YAML format."""
|
||||||
|
return """
|
||||||
- name: Geomob London
|
- name: Geomob London
|
||||||
|
series: geomob-london
|
||||||
topic: Maps
|
topic: Maps
|
||||||
location: London
|
location: London
|
||||||
country: gb
|
country: gb
|
||||||
start: 2026-01-28 18:00:00+00:00
|
dates:
|
||||||
end: 2026-01-28 23:00:00+00:00
|
status: exact
|
||||||
|
start: 2026-01-28 18:00:00+00:00
|
||||||
|
end: 2026-01-28 22:00:00+00:00
|
||||||
url: https://thegeomob.com/post/jan-28th-2026-geomoblon-details
|
url: https://thegeomob.com/post/jan-28th-2026-geomoblon-details
|
||||||
venue: Geovation Hub
|
venue: Geovation Hub
|
||||||
address: Sutton Yard, 65 Goswell Rd, London EC1V 7EN
|
address: Sutton Yard, 65 Goswell Rd, London EC1V 7EN
|
||||||
latitude: 51.5242464
|
latitude: 51.5242464
|
||||||
longitude: -0.0997024
|
longitude: -0.0997024
|
||||||
free: true
|
free: true
|
||||||
going: true
|
|
||||||
hashtag: '#geomobLON'
|
hashtag: '#geomobLON'
|
||||||
|
|
||||||
- name: DebConf 25
|
- name: DebConf 25
|
||||||
|
series: debconf
|
||||||
topic: Debian
|
topic: Debian
|
||||||
location: Plouzané (Breast)
|
location: Plouzane
|
||||||
country: fr
|
country: fr
|
||||||
start: 2025-07-07
|
dates:
|
||||||
end: 2025-07-20
|
status: exact
|
||||||
|
start: 2025-07-07
|
||||||
|
end: 2025-07-20
|
||||||
url: https://wiki.debian.org/DebConf/25
|
url: https://wiki.debian.org/DebConf/25
|
||||||
going: true
|
|
||||||
cfp_url: https://debconf25.debconf.org/talks/new/
|
cfp_url: https://debconf25.debconf.org/talks/new/
|
||||||
venue: École nationale supérieure Mines-Télécom Atlantique Bretagne Pays de la Loire
|
venue: Ecole nationale superieure Mines-Telecom Atlantique Bretagne Pays de la Loire
|
||||||
campus de Brest
|
campus de Brest
|
||||||
latitude: 48.35934
|
latitude: 48.35934
|
||||||
longitude: -4.569889
|
longitude: -4.569889
|
||||||
|
|
||||||
- name: Wikimedia Hackathon
|
- name: Wikimedia Hackathon
|
||||||
|
series: wikimedia-hackathon
|
||||||
topic: Wikimedia
|
topic: Wikimedia
|
||||||
location: Istanbul
|
location: Albania
|
||||||
country: tr
|
country: al
|
||||||
start: 2025-05-02
|
dates:
|
||||||
end: 2025-05-04
|
status: approximate
|
||||||
venue: Renaissance Polat Istanbul Hotel
|
label: mid-April 2027
|
||||||
address: Yeşilyurt, Sahil Yolu Cd. No:2, 34149 Bakırköy/İstanbul
|
earliest: 2027-04-11
|
||||||
latitude: 40.959946
|
latest: 2027-04-20
|
||||||
longitude: 28.838763
|
url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2027
|
||||||
url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2025
|
|
||||||
going: true
|
|
||||||
free: true
|
|
||||||
hackathon: true
|
hackathon: true
|
||||||
registered: true
|
|
||||||
|
- name: PyCascades
|
||||||
|
series: pycascades
|
||||||
|
topic: Python
|
||||||
|
location: Seattle, Washington
|
||||||
|
country: us
|
||||||
|
dates:
|
||||||
|
status: approximate
|
||||||
|
label: March 2027
|
||||||
|
earliest: 2027-03-01
|
||||||
|
latest: 2027-03-31
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def series_prompt_text(series: dict[str, ConferenceSeries]) -> str:
|
||||||
|
"""Return compact known series text for the LLM prompt."""
|
||||||
|
if not series:
|
||||||
|
return "No known conference series loaded."
|
||||||
|
|
||||||
|
lines = ["Known conference series IDs:"]
|
||||||
|
for series_id, item in sorted(series.items()):
|
||||||
|
details = [item["name"]]
|
||||||
|
if topic := item.get("topic"):
|
||||||
|
details.append(f"topic: {topic}")
|
||||||
|
if location := item.get("usual_location"):
|
||||||
|
details.append(f"usual location: {location}")
|
||||||
|
if country := item.get("country"):
|
||||||
|
details.append(f"country: {country}")
|
||||||
|
lines.append(f"- {series_id}: " + "; ".join(details))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(
|
||||||
|
url: str,
|
||||||
|
source_text: str,
|
||||||
|
detected_coordinates: tuple[float, float] | None,
|
||||||
|
series: dict[str, ConferenceSeries] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build prompt with embedded YAML format details and examples."""
|
||||||
coordinate_note = ""
|
coordinate_note = ""
|
||||||
if detected_coordinates is not None:
|
if detected_coordinates is not None:
|
||||||
coordinate_note = (
|
coordinate_note = (
|
||||||
|
|
@ -99,28 +180,26 @@ def build_prompt(
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
I keep a record of interesting conferences in a YAML file.
|
I keep a record of interesting conferences in a YAML file.
|
||||||
|
|
||||||
|
Format rules:
|
||||||
|
|
||||||
|
{conference_yaml_format_description()}
|
||||||
|
|
||||||
|
{series_prompt_text(series or {})}
|
||||||
|
|
||||||
Here are some examples of the format I use:
|
Here are some examples of the format I use:
|
||||||
|
|
||||||
{examples}
|
{yaml_example_text()}
|
||||||
|
|
||||||
Now here is a new conference of interest:
|
Now here is a new conference of interest:
|
||||||
|
|
||||||
Conference URL: {url}
|
Conference URL: {url}
|
||||||
|
|
||||||
Return the YAML representation for this conference following the
|
Return the YAML representation for this conference following the same style and
|
||||||
same style and keys as the examples. Only include keys if the
|
keys as the examples. Only include keys if the information is available. Do not
|
||||||
information is available. Do not invent details.
|
invent details.
|
||||||
|
|
||||||
Important: the `country` field must always be a valid ISO 3166-1 alpha-2
|
Important: if this is a Geomob event, use a `dates.end` datetime of 22:00 local
|
||||||
country code (two lowercase letters, e.g. `ca` for Canada, `gb` for United Kingdom).
|
time on the event date unless the page explicitly provides a different end time.
|
||||||
Do not output full country names.
|
|
||||||
|
|
||||||
Important: always include an `end` field. If the event is a single-day event,
|
|
||||||
the `end` can be the same date as `start`, or a same-day datetime if the page
|
|
||||||
provides an end time.
|
|
||||||
|
|
||||||
Important: if this is a Geomob event, use an `end` datetime of 22:00 local time
|
|
||||||
on the event date unless the page explicitly provides a different end time.
|
|
||||||
{coordinate_note}
|
{coordinate_note}
|
||||||
|
|
||||||
Wrap your answer in a JSON object with a single key "yaml".
|
Wrap your answer in a JSON object with a single key "yaml".
|
||||||
|
|
@ -261,9 +340,26 @@ def parse_date(date_str: str) -> datetime:
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def data_dir_from_conferences_path(yaml_path: str) -> str:
|
||||||
|
"""Return personal-data directory from a conferences.yaml path."""
|
||||||
|
return os.path.dirname(os.path.abspath(yaml_path))
|
||||||
|
|
||||||
|
|
||||||
def url_has_year_component(url: str) -> bool:
|
def url_has_year_component(url: str) -> bool:
|
||||||
"""Return True if the URL contains any digit."""
|
"""Return True if the URL contains a year or edition path component."""
|
||||||
return any(ch.isdigit() for ch in url)
|
parsed = urlparse(url)
|
||||||
|
components = [part for part in parsed.path.split("/") if part]
|
||||||
|
if parsed.netloc:
|
||||||
|
components.extend(part for part in parsed.netloc.split(".") if part)
|
||||||
|
|
||||||
|
for component in components:
|
||||||
|
if re.fullmatch(r"20\d{2}", component):
|
||||||
|
return True
|
||||||
|
if re.search(r"(?:^|[-_/])20\d{2}(?:$|[-_/])", component):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r"\d{1,2}x", component, flags=re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def insert_sorted(
|
def insert_sorted(
|
||||||
|
|
@ -273,6 +369,13 @@ def insert_sorted(
|
||||||
new_url = new_conf.get("url")
|
new_url = new_conf.get("url")
|
||||||
new_start = conference_sort_datetime(new_conf)
|
new_start = conference_sort_datetime(new_conf)
|
||||||
new_year = new_start.year
|
new_year = new_start.year
|
||||||
|
update_idx = find_inexact_existing_conference(conferences, new_conf)
|
||||||
|
if update_idx is not None:
|
||||||
|
existing = conferences.pop(update_idx)
|
||||||
|
merged = dict(existing)
|
||||||
|
merged.update(new_conf)
|
||||||
|
print(f"Updating inexact conference entry: {existing.get('name')}")
|
||||||
|
return insert_sorted(conferences, merged)
|
||||||
|
|
||||||
if new_url:
|
if new_url:
|
||||||
for conf in conferences:
|
for conf in conferences:
|
||||||
|
|
@ -299,6 +402,56 @@ def insert_sorted(
|
||||||
return conferences
|
return conferences
|
||||||
|
|
||||||
|
|
||||||
|
def date_ranges_overlap(
|
||||||
|
first: dict[str, typing.Any], second: dict[str, typing.Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if two conference date ranges overlap."""
|
||||||
|
first_fields = conference_date_fields(first)
|
||||||
|
second_fields = conference_date_fields(second)
|
||||||
|
return typing.cast(date, first_fields["start_date"]) <= typing.cast(
|
||||||
|
date, second_fields["end_date"]
|
||||||
|
) and typing.cast(date, second_fields["start_date"]) <= typing.cast(
|
||||||
|
date, first_fields["end_date"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def same_conference_identity(
|
||||||
|
existing: dict[str, typing.Any], new_conf: dict[str, typing.Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if two entries appear to represent the same conference."""
|
||||||
|
existing_url = existing.get("url")
|
||||||
|
new_url = new_conf.get("url")
|
||||||
|
if existing_url and new_url and existing_url == new_url:
|
||||||
|
return True
|
||||||
|
|
||||||
|
existing_series = existing.get("series")
|
||||||
|
new_series = new_conf.get("series")
|
||||||
|
if existing_series and new_series and existing_series == new_series:
|
||||||
|
return date_ranges_overlap(existing, new_conf)
|
||||||
|
|
||||||
|
return str(existing.get("name", "")).casefold() == str(
|
||||||
|
new_conf.get("name", "")
|
||||||
|
).casefold() and date_ranges_overlap(existing, new_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def find_inexact_existing_conference(
|
||||||
|
conferences: list[dict[str, typing.Any]], new_conf: dict[str, typing.Any]
|
||||||
|
) -> int | None:
|
||||||
|
"""Return index of an inexact existing entry that exact new data can update."""
|
||||||
|
new_fields = conference_date_fields(new_conf)
|
||||||
|
if new_fields["date_status"] != "exact":
|
||||||
|
return None
|
||||||
|
|
||||||
|
for idx, existing in enumerate(conferences):
|
||||||
|
existing_fields = conference_date_fields(existing)
|
||||||
|
if existing_fields["date_status"] == "exact":
|
||||||
|
continue
|
||||||
|
if same_conference_identity(existing, new_conf):
|
||||||
|
return idx
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
|
def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
|
||||||
"""Return conference sort date as a datetime."""
|
"""Return conference sort date as a datetime."""
|
||||||
sort_date = conference_date_fields(conf)["sort_date"]
|
sort_date = conference_date_fields(conf)["sort_date"]
|
||||||
|
|
@ -349,6 +502,42 @@ def parse_yaml_datetime(value: typing.Any) -> datetime | None:
|
||||||
return 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(
|
def same_type_as_start(
|
||||||
start_value: typing.Any,
|
start_value: typing.Any,
|
||||||
new_dt: datetime,
|
new_dt: datetime,
|
||||||
|
|
@ -374,6 +563,46 @@ def same_type_as_start(
|
||||||
return new_dt
|
return new_dt
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if dates is None and ("start" in conf or "end" in conf):
|
||||||
|
start = conf.pop("start", None)
|
||||||
|
end = conf.pop("end", start)
|
||||||
|
status = str(conf.pop("date_status", "exact"))
|
||||||
|
conf["dates"] = {"status": status, "start": start, "end": end}
|
||||||
|
return
|
||||||
|
|
||||||
|
if dates is not None:
|
||||||
|
if "start" in conf and "start" not in dates:
|
||||||
|
dates["start"] = conf["start"]
|
||||||
|
if "end" in conf and "end" not in dates:
|
||||||
|
dates["end"] = conf["end"]
|
||||||
|
if "date_status" in conf and "status" not in dates:
|
||||||
|
dates["status"] = conf["date_status"]
|
||||||
|
|
||||||
|
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:
|
def maybe_extract_explicit_end_time(source_text: str) -> int | None:
|
||||||
"""Extract an explicit 12-hour clock end time for Geomob-style pages."""
|
"""Extract an explicit 12-hour clock end time for Geomob-style pages."""
|
||||||
lowered = source_text.lower()
|
lowered = source_text.lower()
|
||||||
|
|
@ -435,6 +664,14 @@ def load_conferences(yaml_path: str) -> list[dict[str, typing.Any]]:
|
||||||
return typing.cast(list[dict[str, typing.Any]], loaded)
|
return typing.cast(list[dict[str, typing.Any]], loaded)
|
||||||
|
|
||||||
|
|
||||||
|
def load_conference_series_for_path(yaml_path: str) -> dict[str, ConferenceSeries]:
|
||||||
|
"""Load conference series next to the target conferences YAML file."""
|
||||||
|
return typing.cast(
|
||||||
|
dict[str, ConferenceSeries],
|
||||||
|
load_series(data_dir_from_conferences_path(yaml_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def dump_conferences(yaml_path: str, conferences: list[dict[str, typing.Any]]) -> None:
|
def dump_conferences(yaml_path: str, conferences: list[dict[str, typing.Any]]) -> None:
|
||||||
"""Write conference YAML."""
|
"""Write conference YAML."""
|
||||||
with open(yaml_path, "w") as file:
|
with open(yaml_path, "w") as file:
|
||||||
|
|
@ -450,6 +687,9 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
|
||||||
if url_has_year_component(url):
|
if url_has_year_component(url):
|
||||||
for conf in conferences:
|
for conf in conferences:
|
||||||
if conf.get("url") == url:
|
if conf.get("url") == url:
|
||||||
|
fields = conference_date_fields(conf)
|
||||||
|
if fields["date_status"] != "exact":
|
||||||
|
continue
|
||||||
print(
|
print(
|
||||||
"⚠️ Conference already exists in YAML "
|
"⚠️ Conference already exists in YAML "
|
||||||
+ f"(url={url}), skipping before API call."
|
+ f"(url={url}), skipping before API call."
|
||||||
|
|
@ -459,7 +699,8 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
|
||||||
soup = fetch_webpage(url)
|
soup = fetch_webpage(url)
|
||||||
source_text = webpage_to_text(soup)
|
source_text = webpage_to_text(soup)
|
||||||
detected_coordinates = detect_page_coordinates(soup)
|
detected_coordinates = detect_page_coordinates(soup)
|
||||||
prompt = build_prompt(url, source_text, detected_coordinates)
|
series = load_conference_series_for_path(yaml_path)
|
||||||
|
prompt = build_prompt(url, source_text, detected_coordinates, series)
|
||||||
new_yaml_text = get_from_open_ai(prompt)["yaml"]
|
new_yaml_text = get_from_open_ai(prompt)["yaml"]
|
||||||
|
|
||||||
new_conf = yaml.safe_load(new_yaml_text)
|
new_conf = yaml.safe_load(new_yaml_text)
|
||||||
|
|
@ -468,7 +709,10 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
|
||||||
assert isinstance(new_conf, dict)
|
assert isinstance(new_conf, dict)
|
||||||
|
|
||||||
validate_country(new_conf)
|
validate_country(new_conf)
|
||||||
|
normalize_dates_field(new_conf)
|
||||||
normalise_end_field(new_conf, source_text)
|
normalise_end_field(new_conf, source_text)
|
||||||
|
normalize_dates_field(new_conf)
|
||||||
|
validate_generated_conference(new_conf)
|
||||||
|
|
||||||
if detected_coordinates is not None:
|
if detected_coordinates is not None:
|
||||||
new_conf["latitude"] = detected_coordinates[0]
|
new_conf["latitude"] = detected_coordinates[0]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import decimal
|
import decimal
|
||||||
|
import os
|
||||||
import typing
|
import typing
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
@ -16,6 +17,18 @@ DATE_STATUSES = {"exact", "approximate", "tentative"}
|
||||||
DATED_STATUSES = {"exact", "tentative"}
|
DATED_STATUSES = {"exact", "tentative"}
|
||||||
|
|
||||||
|
|
||||||
|
class ConferenceSeries(typing.TypedDict, total=False):
|
||||||
|
"""Conference series metadata."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
topic: str
|
||||||
|
url: str
|
||||||
|
notes: str
|
||||||
|
cadence: str
|
||||||
|
usual_location: str
|
||||||
|
country: str
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Conference:
|
class Conference:
|
||||||
"""Conference."""
|
"""Conference."""
|
||||||
|
|
@ -23,6 +36,7 @@ class Conference:
|
||||||
name: str
|
name: str
|
||||||
topic: str
|
topic: str
|
||||||
location: str
|
location: str
|
||||||
|
series: str | None = None
|
||||||
start: date | datetime | None = None
|
start: date | datetime | None = None
|
||||||
end: date | datetime | None = None
|
end: date | datetime | None = None
|
||||||
trip: date | None = None
|
trip: date | None = None
|
||||||
|
|
@ -166,6 +180,20 @@ def validate_conference_date_fields(item: StrDict) -> StrDict:
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def load_series(data_dir: str) -> dict[str, ConferenceSeries]:
|
||||||
|
"""Load conference series metadata."""
|
||||||
|
filepath = os.path.join(data_dir, "conference_series.yaml")
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
loaded = yaml.safe_load(open(filepath, "r"))
|
||||||
|
if loaded is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ValueError("conference_series.yaml must be a mapping")
|
||||||
|
return typing.cast(dict[str, ConferenceSeries], loaded)
|
||||||
|
|
||||||
|
|
||||||
def get_list(filepath: str) -> list[Event]:
|
def get_list(filepath: str) -> list[Event]:
|
||||||
"""Read conferences from a YAML file and return a list of Event objects."""
|
"""Read conferences from a YAML file and return a list of Event objects."""
|
||||||
events: list[Event] = []
|
events: list[Event] = []
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import flask
|
||||||
import pycountry
|
import pycountry
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from agenda import ical, travel, trip_schengen
|
from agenda import conference, ical, travel, trip_schengen
|
||||||
from agenda.types import StrDict, Trip, TripElement
|
from agenda.types import StrDict, Trip, TripElement
|
||||||
from agenda.utils import as_date, as_datetime, depart_datetime
|
from agenda.utils import as_date, as_datetime, depart_datetime
|
||||||
|
|
||||||
|
|
@ -333,12 +333,18 @@ def build_trip_list(
|
||||||
yaml_trip_list = travel.parse_yaml("trips", data_dir)
|
yaml_trip_list = travel.parse_yaml("trips", data_dir)
|
||||||
|
|
||||||
flight_bookings = load_flight_bookings(data_dir)
|
flight_bookings = load_flight_bookings(data_dir)
|
||||||
|
conferences = travel.parse_yaml("conferences", data_dir)
|
||||||
|
for conf in conferences:
|
||||||
|
date_fields = conference.conference_date_fields(conf)
|
||||||
|
if "start" in date_fields:
|
||||||
|
conf["start"] = date_fields["start"]
|
||||||
|
conf["end"] = date_fields["end"]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"flight_bookings": flight_bookings,
|
"flight_bookings": flight_bookings,
|
||||||
"travel": collect_travel_items(flight_bookings, data_dir, route_distances),
|
"travel": collect_travel_items(flight_bookings, data_dir, route_distances),
|
||||||
"accommodation": travel.parse_yaml("accommodation", data_dir),
|
"accommodation": travel.parse_yaml("accommodation", data_dir),
|
||||||
"conferences": travel.parse_yaml("conferences", data_dir),
|
"conferences": conferences,
|
||||||
"events": travel.parse_yaml("events", data_dir),
|
"events": travel.parse_yaml("events", data_dir),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -467,10 +473,10 @@ def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def conf_attend_start(c: StrDict) -> date:
|
def conf_attend_start(c: StrDict) -> date:
|
||||||
return as_date(c.get("attend_start") or c["start"])
|
return typing.cast(date, as_date(c.get("attend_start") or c["start"]))
|
||||||
|
|
||||||
def conf_attend_end(c: StrDict) -> date:
|
def conf_attend_end(c: StrDict) -> date:
|
||||||
return as_date(c.get("attend_end") or c["end"])
|
return typing.cast(date, as_date(c.get("attend_end") or c["end"]))
|
||||||
|
|
||||||
sorted_confs = sorted(trip.conferences, key=conf_attend_start)
|
sorted_confs = sorted(trip.conferences, key=conf_attend_start)
|
||||||
result: dict[str, tuple[int, int]] = {}
|
result: dict[str, tuple[int, int]] = {}
|
||||||
|
|
@ -864,7 +870,7 @@ def _trip_element_label(element: TripElement) -> str:
|
||||||
return start_loc
|
return start_loc
|
||||||
if isinstance(end_loc, str):
|
if isinstance(end_loc, str):
|
||||||
return end_loc
|
return end_loc
|
||||||
return element.title
|
return typing.cast(str, element.title)
|
||||||
|
|
||||||
|
|
||||||
def _trip_element_summary(trip: Trip, element: TripElement) -> str:
|
def _trip_element_summary(trip: Trip, element: TripElement) -> str:
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@ Legacy fields:
|
||||||
|
|
||||||
Common optional fields:
|
Common optional fields:
|
||||||
|
|
||||||
|
- Series: `series`, a key from `conference_series.yaml`.
|
||||||
- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`.
|
- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`.
|
||||||
- Attendance: `going`, `registered`, `speaking`, `online`, `accommodation_booked`, `transport_booked`.
|
- Attendance: `going`, `registered`, `speaking`, `online`, `accommodation_booked`, `transport_booked`.
|
||||||
- Partial attendance: `attend_start`, `attend_end`. These may be dates or timezone-aware datetimes and are used on trip pages instead of official dates.
|
- Partial attendance: `attend_start`, `attend_end`. These may be dates or timezone-aware datetimes and are used on trip pages instead of official dates.
|
||||||
|
|
@ -294,6 +295,7 @@ Exact example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: FOSDEM
|
- name: FOSDEM
|
||||||
|
series: fosdem
|
||||||
topic: FOSDEM
|
topic: FOSDEM
|
||||||
location: Brussels
|
location: Brussels
|
||||||
country: be
|
country: be
|
||||||
|
|
@ -319,6 +321,7 @@ Tentative example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: FOSDEM
|
- name: FOSDEM
|
||||||
|
series: fosdem
|
||||||
topic: FOSDEM
|
topic: FOSDEM
|
||||||
location: Brussels
|
location: Brussels
|
||||||
country: be
|
country: be
|
||||||
|
|
@ -335,6 +338,7 @@ Approximate examples:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Wikimedia Hackathon 2027
|
- name: Wikimedia Hackathon 2027
|
||||||
|
series: wikimedia-hackathon
|
||||||
topic: Wikimedia
|
topic: Wikimedia
|
||||||
location: Albania
|
location: Albania
|
||||||
country: al
|
country: al
|
||||||
|
|
@ -346,6 +350,7 @@ Approximate examples:
|
||||||
hackathon: true
|
hackathon: true
|
||||||
|
|
||||||
- name: PyCascades 2027
|
- name: PyCascades 2027
|
||||||
|
series: pycascades
|
||||||
topic: Python
|
topic: Python
|
||||||
location: TBC
|
location: TBC
|
||||||
dates:
|
dates:
|
||||||
|
|
@ -355,6 +360,46 @@ Approximate examples:
|
||||||
latest: 2027-03-31
|
latest: 2027-03-31
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `conference_series.yaml`
|
||||||
|
|
||||||
|
Top-level shape: mapping from stable series ID to series metadata.
|
||||||
|
|
||||||
|
Used by: conference list pages, conference series index/detail pages, and validation of `conferences.yaml` `series` references.
|
||||||
|
|
||||||
|
Required fields for each series:
|
||||||
|
|
||||||
|
- `name`: display name for the series.
|
||||||
|
|
||||||
|
Common optional fields:
|
||||||
|
|
||||||
|
- `topic`: default topic/category.
|
||||||
|
- `cadence`: for example `annual` or `recurring`.
|
||||||
|
- `usual_location`: common city/place when the event usually stays in one place.
|
||||||
|
- `country`: common lowercase country code when stable.
|
||||||
|
- `url`: series homepage.
|
||||||
|
- `notes`: free-text generation or scheduling notes.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
fosdem:
|
||||||
|
name: FOSDEM
|
||||||
|
topic: FOSDEM
|
||||||
|
cadence: annual
|
||||||
|
usual_location: Brussels
|
||||||
|
country: be
|
||||||
|
url: https://fosdem.org/
|
||||||
|
notes: Usually the weekend where Sunday is the first Sunday in February.
|
||||||
|
|
||||||
|
geomob-london:
|
||||||
|
name: Geomob London
|
||||||
|
topic: Maps
|
||||||
|
cadence: recurring
|
||||||
|
usual_location: London
|
||||||
|
country: gb
|
||||||
|
url: https://thegeomob.com/
|
||||||
|
```
|
||||||
|
|
||||||
## `entities.yaml`
|
## `entities.yaml`
|
||||||
|
|
||||||
Top-level shape: list of people/entities.
|
Top-level shape: list of people/entities.
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ tr.conf-hl > td {
|
||||||
{% if item_list %}
|
{% if item_list %}
|
||||||
{% set count = item_list | length %}
|
{% set count = item_list | length %}
|
||||||
<tr class="conf-section-row">
|
<tr class="conf-section-row">
|
||||||
<td colspan="6">{{ heading }} <span class="fw-normal opacity-75">{{ count }} conference{{ "" if count == 1 else "s" }}</span></td>
|
<td colspan="7">{{ heading }} <span class="fw-normal opacity-75">{{ count }} conference{{ "" if count == 1 else "s" }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% set ns = namespace(prev_month="") %}
|
{% set ns = namespace(prev_month="") %}
|
||||||
{% for item in item_list %}
|
{% for item in item_list %}
|
||||||
|
|
@ -142,7 +142,7 @@ tr.conf-hl > td {
|
||||||
{% if month_label != ns.prev_month %}
|
{% if month_label != ns.prev_month %}
|
||||||
{% set ns.prev_month = month_label %}
|
{% set ns.prev_month = month_label %}
|
||||||
<tr class="conf-month-row">
|
<tr class="conf-month-row">
|
||||||
<td colspan="6">{{ month_label }}</td>
|
<td colspan="7">{{ month_label }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.sort_date.isoformat() }}|{{ item.name }}">
|
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.sort_date.isoformat() }}|{{ item.name }}">
|
||||||
|
|
@ -176,6 +176,13 @@ tr.conf-hl > td {
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-muted small">{{ item.topic }}</td>
|
<td class="text-muted small">{{ item.topic }}</td>
|
||||||
|
<td class="text-nowrap text-muted small">
|
||||||
|
{% if item.series and item.series_detail %}
|
||||||
|
<a href="{{ url_for('conference_series_page', series_id=item.series) }}">{{ item.series_detail.name }}</a>
|
||||||
|
{% elif item.series %}
|
||||||
|
{{ item.series }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
{% set country = get_country(item.country) if item.country else None %}
|
{% set country = get_country(item.country) if item.country else None %}
|
||||||
{% if country %}{{ country.flag }} {{ item.location }}
|
{% if country %}{{ country.flag }} {{ item.location }}
|
||||||
|
|
@ -213,6 +220,7 @@ tr.conf-hl > td {
|
||||||
<col>
|
<col>
|
||||||
<col style="width: 18rem">
|
<col style="width: 18rem">
|
||||||
<col style="width: 14rem">
|
<col style="width: 14rem">
|
||||||
|
<col style="width: 14rem">
|
||||||
<col style="width: 7rem">
|
<col style="width: 7rem">
|
||||||
<col style="width: 10rem">
|
<col style="width: 10rem">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
|
|
@ -221,6 +229,7 @@ tr.conf-hl > td {
|
||||||
<th>Dates</th>
|
<th>Dates</th>
|
||||||
<th>Conference</th>
|
<th>Conference</th>
|
||||||
<th>Topic</th>
|
<th>Topic</th>
|
||||||
|
<th>Series</th>
|
||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>CFP ends</th>
|
<th>CFP ends</th>
|
||||||
<th>Price</th>
|
<th>Price</th>
|
||||||
|
|
|
||||||
85
templates/conference_series.html
Normal file
85
templates/conference_series.html
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ series.name }} - Edward Betts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid mt-2">
|
||||||
|
<h1>{{ series.name }}</h1>
|
||||||
|
|
||||||
|
<dl class="row">
|
||||||
|
{% if series.topic %}
|
||||||
|
<dt class="col-sm-2">Topic</dt>
|
||||||
|
<dd class="col-sm-10">{{ series.topic }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if series.usual_location or series.country %}
|
||||||
|
<dt class="col-sm-2">Usual location</dt>
|
||||||
|
<dd class="col-sm-10">
|
||||||
|
{% set country = get_country(series.country) if series.country else None %}
|
||||||
|
{% if country %}{{ country.flag }} {% endif %}{{ series.usual_location or "" }}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if series.cadence %}
|
||||||
|
<dt class="col-sm-2">Cadence</dt>
|
||||||
|
<dd class="col-sm-10">{{ series.cadence }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if series.url %}
|
||||||
|
<dt class="col-sm-2">Website</dt>
|
||||||
|
<dd class="col-sm-10"><a href="{{ series.url }}">{{ series.url }}</a></dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if series.notes %}
|
||||||
|
<dt class="col-sm-2">Notes</dt>
|
||||||
|
<dd class="col-sm-10">{{ series.notes }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2>Editions</h2>
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Dates</th>
|
||||||
|
<th>Conference</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Attendance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in conferences %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap text-muted small">
|
||||||
|
{{ item.display_date }}
|
||||||
|
{% if item.date_status == "tentative" %}
|
||||||
|
<span class="badge text-bg-warning ms-1">tentative</span>
|
||||||
|
{% elif item.date_status == "approximate" %}
|
||||||
|
<span class="badge text-bg-secondary ms-1">approximate</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.url %}<a href="{{ item.url }}">{{ item.name }}</a>
|
||||||
|
{% else %}{{ item.name }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set country = get_country(item.country) if item.country else None %}
|
||||||
|
{% if country %}{{ country.flag }} {% endif %}{{ item.location }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.going %}
|
||||||
|
<span class="badge text-bg-success">going</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.registered %}
|
||||||
|
<span class="badge text-bg-primary">registered</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.linked_trip %}
|
||||||
|
{% set trip = item.linked_trip %}
|
||||||
|
<a href="{{ url_for('trip_page', start=trip.start.isoformat()) }}"
|
||||||
|
class="ms-1"
|
||||||
|
title="Trip: {{ trip.title }}">
|
||||||
|
trip{% if trip.title != item.name %}: {{ trip.title }}{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
47
templates/conference_series_list.html
Normal file
47
templates/conference_series_list.html
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Conference Series - Edward Betts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid mt-2">
|
||||||
|
<h1>Conference Series</h1>
|
||||||
|
|
||||||
|
<table class="table table-sm table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Series</th>
|
||||||
|
<th>Topic</th>
|
||||||
|
<th>Usual location</th>
|
||||||
|
<th>Conferences</th>
|
||||||
|
<th>Next</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in series_list %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('conference_series_page', series_id=item.id) }}">{{ item.name }}</a>
|
||||||
|
{% if item.attended %}
|
||||||
|
<span class="badge text-bg-success ms-1">attended</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.url %}
|
||||||
|
<a class="text-muted ms-1 text-decoration-none" href="{{ item.url }}">↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ item.topic or "" }}</td>
|
||||||
|
<td>
|
||||||
|
{% set country = get_country(item.country) if item.country else None %}
|
||||||
|
{% if country %}{{ country.flag }} {% endif %}{{ item.usual_location or "" }}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.count }}</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{% if item.next_conf %}
|
||||||
|
{{ item.next_conf.display_date }} · {{ item.next_conf.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
{% set conference_pages = [
|
{% set conference_pages = [
|
||||||
{"endpoint": "conference_list", "label": "Conferences" },
|
{"endpoint": "conference_list", "label": "Conferences" },
|
||||||
{"endpoint": "past_conference_list", "label": "Past conferences" },
|
{"endpoint": "past_conference_list", "label": "Past conferences" },
|
||||||
|
{"endpoint": "conference_series_list", "label": "Conference series" },
|
||||||
] %}
|
] %}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,7 +64,7 @@
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- Conference dropdown -->
|
<!-- Conference dropdown -->
|
||||||
{% set conference_active = request.endpoint in ['conference_list', 'past_conference_list'] %}
|
{% set conference_active = request.endpoint in ['conference_list', 'past_conference_list', 'conference_series_list', 'conference_series_page'] %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle{% if conference_active %} border border-white border-2 active{% endif %}" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a class="nav-link dropdown-toggle{% if conference_active %} border border-white border-2 active{% endif %}" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Conferences
|
Conferences
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,19 @@ def test_extract_google_maps_latlon_at_pattern() -> None:
|
||||||
assert result == (51.5242464, -0.0997024)
|
assert result == (51.5242464, -0.0997024)
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_has_year_component() -> None:
|
||||||
|
"""Only actual year or edition components should count as year-specific."""
|
||||||
|
cases = [
|
||||||
|
("https://www.foss4gna.org/", False),
|
||||||
|
("https://foss4g.asia/2026/", True),
|
||||||
|
("https://2027.fossy.ca/", True),
|
||||||
|
("https://www.socallinuxexpo.org/scale/24x/", True),
|
||||||
|
("https://2026.stateofthebrowser.com/", True),
|
||||||
|
]
|
||||||
|
for url, expected in cases:
|
||||||
|
assert add_new_conference.url_has_year_component(url) is expected
|
||||||
|
|
||||||
|
|
||||||
def test_insert_sorted_allows_same_url_different_year_without_year_component() -> None:
|
def test_insert_sorted_allows_same_url_different_year_without_year_component() -> None:
|
||||||
"""The same non-year-specific URL can be reused for a different year."""
|
"""The same non-year-specific URL can be reused for a different year."""
|
||||||
conferences: list[dict[str, typing.Any]] = [
|
conferences: list[dict[str, typing.Any]] = [
|
||||||
|
|
@ -74,6 +87,115 @@ def test_insert_sorted_supports_nested_dates() -> None:
|
||||||
assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"]
|
assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_sorted_updates_inexact_existing_entry() -> None:
|
||||||
|
"""Exact dates should replace an existing inexact series entry."""
|
||||||
|
conferences: list[dict[str, typing.Any]] = [
|
||||||
|
{
|
||||||
|
"name": "PyCascades",
|
||||||
|
"series": "pycascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"location": "Seattle, Washington",
|
||||||
|
"dates": {
|
||||||
|
"status": "approximate",
|
||||||
|
"label": "March 2027",
|
||||||
|
"earliest": date(2027, 3, 1),
|
||||||
|
"latest": date(2027, 3, 31),
|
||||||
|
},
|
||||||
|
"url": "https://2027.pycascades.com/",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
new_conf: dict[str, typing.Any] = {
|
||||||
|
"name": "PyCascades",
|
||||||
|
"series": "pycascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"location": "Seattle, Washington",
|
||||||
|
"dates": {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2027, 3, 12),
|
||||||
|
"end": date(2027, 3, 14),
|
||||||
|
},
|
||||||
|
"url": "https://2027.pycascades.com/",
|
||||||
|
"venue": "Example Hall",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = add_new_conference.insert_sorted(conferences, new_conf)
|
||||||
|
|
||||||
|
assert len(updated) == 1
|
||||||
|
assert updated[0]["dates"]["status"] == "exact"
|
||||||
|
assert updated[0]["dates"]["start"] == date(2027, 3, 12)
|
||||||
|
assert updated[0]["venue"] == "Example Hall"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_dates_field_moves_legacy_dates() -> None:
|
||||||
|
"""Legacy start/end model output should be converted before writing YAML."""
|
||||||
|
conf: dict[str, typing.Any] = {
|
||||||
|
"name": "PyCon",
|
||||||
|
"start": date(2026, 4, 10),
|
||||||
|
"end": date(2026, 4, 12),
|
||||||
|
}
|
||||||
|
|
||||||
|
add_new_conference.normalize_dates_field(conf)
|
||||||
|
|
||||||
|
assert "start" not in conf
|
||||||
|
assert "end" not in conf
|
||||||
|
assert conf["dates"] == {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2026, 4, 10),
|
||||||
|
"end": date(2026, 4, 12),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"https://example.com",
|
||||||
|
"Conference details",
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"pycascades": {
|
||||||
|
"name": "PyCascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"usual_location": "Seattle, Washington",
|
||||||
|
"country": "us",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Do not output legacy top-level `start`, `end`, or `date_status`" in prompt
|
||||||
|
assert "dates.status" in prompt
|
||||||
|
assert "- pycascades: PyCascades" in prompt
|
||||||
|
assert "March 2027" in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_validate_country_normalises_name() -> None:
|
def test_validate_country_normalises_name() -> None:
|
||||||
"""Country names should be normalised to alpha-2 codes."""
|
"""Country names should be normalised to alpha-2 codes."""
|
||||||
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
||||||
|
|
@ -197,6 +319,76 @@ def test_add_new_conference_updates_yaml(
|
||||||
assert len(written) == 2
|
assert len(written) == 2
|
||||||
assert written[1]["name"] == "NewConf"
|
assert written[1]["name"] == "NewConf"
|
||||||
assert written[1]["country"] == "us"
|
assert written[1]["country"] == "us"
|
||||||
assert written[1]["end"] == date(2026, 5, 3)
|
assert written[1]["dates"] == {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2026, 5, 3),
|
||||||
|
"end": date(2026, 5, 3),
|
||||||
|
}
|
||||||
assert written[1]["latitude"] == 40.0
|
assert written[1]["latitude"] == 40.0
|
||||||
assert written[1]["longitude"] == -74.0
|
assert written[1]["longitude"] == -74.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_new_conference_reuses_generic_url_for_new_year(
|
||||||
|
tmp_path: typing.Any, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Generic URLs with digits in the domain should not be skipped early."""
|
||||||
|
yaml_path = tmp_path / "conferences.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
yaml.dump(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "FOSS4G North America",
|
||||||
|
"series": "foss4g-north-america",
|
||||||
|
"dates": {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2025, 11, 3),
|
||||||
|
"end": date(2025, 11, 5),
|
||||||
|
},
|
||||||
|
"url": "https://www.foss4gna.org/",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
root = lxml.html.fromstring("<html><body>Conference details</body></html>")
|
||||||
|
monkeypatch.setattr(add_new_conference, "fetch_webpage", lambda url: root)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
add_new_conference,
|
||||||
|
"webpage_to_text",
|
||||||
|
lambda parsed: "FOSS4G North America 2026",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
add_new_conference, "detect_page_coordinates", lambda parsed: None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
add_new_conference,
|
||||||
|
"get_from_open_ai",
|
||||||
|
lambda prompt: {
|
||||||
|
"yaml": yaml.dump(
|
||||||
|
{
|
||||||
|
"name": "FOSS4G North America",
|
||||||
|
"series": "foss4g-north-america",
|
||||||
|
"topic": "Geospatial",
|
||||||
|
"location": "St. Louis, Missouri",
|
||||||
|
"country": "us",
|
||||||
|
"dates": {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2026, 10, 26),
|
||||||
|
"end": date(2026, 10, 29),
|
||||||
|
},
|
||||||
|
"url": "https://www.foss4gna.org/",
|
||||||
|
},
|
||||||
|
sort_keys=False,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
added = add_new_conference.add_new_conference(
|
||||||
|
"https://www.foss4gna.org/", str(yaml_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert added is True
|
||||||
|
written = yaml.safe_load(yaml_path.read_text())
|
||||||
|
assert len(written) == 2
|
||||||
|
assert [conf["dates"]["start"].year for conf in written] == [2025, 2026]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import typing
|
import typing
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ def test_build_conference_list_supports_inexact_dates(
|
||||||
conferences = [
|
conferences = [
|
||||||
{
|
{
|
||||||
"name": "PyCascades 2027",
|
"name": "PyCascades 2027",
|
||||||
|
"series": "pycascades",
|
||||||
"topic": "Python",
|
"topic": "Python",
|
||||||
"location": "TBC",
|
"location": "TBC",
|
||||||
"dates": {
|
"dates": {
|
||||||
|
|
@ -40,6 +42,19 @@ def test_build_conference_list_supports_inexact_dates(
|
||||||
(tmp_path / "conferences.yaml").write_text(
|
(tmp_path / "conferences.yaml").write_text(
|
||||||
yaml.safe_dump(conferences), encoding="utf-8"
|
yaml.safe_dump(conferences), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
(tmp_path / "conference_series.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"pycascades": {
|
||||||
|
"name": "PyCascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"cadence": "annual",
|
||||||
|
"url": "https://pycascades.com/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
|
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
|
||||||
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
|
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
|
||||||
|
|
@ -53,3 +68,56 @@ def test_build_conference_list_supports_inexact_dates(
|
||||||
assert items[1]["date_status"] == "approximate"
|
assert items[1]["date_status"] == "approximate"
|
||||||
assert items[1]["display_date"] == "March 2027"
|
assert items[1]["display_date"] == "March 2027"
|
||||||
assert items[1]["latest_date"] == date(2027, 3, 31)
|
assert items[1]["latest_date"] == date(2027, 3, 31)
|
||||||
|
assert items[1]["series_detail"]["name"] == "PyCascades"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conference_series_pages(tmp_path: typing.Any, monkeypatch: typing.Any) -> None:
|
||||||
|
"""Series index and detail pages should render linked conferences."""
|
||||||
|
conferences = [
|
||||||
|
{
|
||||||
|
"name": "PyCascades 2027",
|
||||||
|
"series": "pycascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"location": "TBC",
|
||||||
|
"trip": date(2027, 3, 1),
|
||||||
|
"dates": {
|
||||||
|
"status": "exact",
|
||||||
|
"start": date(2027, 3, 5),
|
||||||
|
"end": date(2027, 3, 6),
|
||||||
|
},
|
||||||
|
"going": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
series = {
|
||||||
|
"pycascades": {
|
||||||
|
"name": "PyCascades",
|
||||||
|
"topic": "Python",
|
||||||
|
"cadence": "annual",
|
||||||
|
"url": "https://pycascades.com/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tmp_path / "conferences.yaml").write_text(
|
||||||
|
yaml.safe_dump(conferences), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(tmp_path / "conference_series.yaml").write_text(
|
||||||
|
yaml.safe_dump(series), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
|
||||||
|
fake_trip = SimpleNamespace(
|
||||||
|
start=date(2027, 3, 1),
|
||||||
|
title="Seattle Python trip",
|
||||||
|
conferences=[{"start": date(2027, 3, 5), "name": "PyCascades 2027"}],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [fake_trip])
|
||||||
|
|
||||||
|
web_view.app.config["TESTING"] = True
|
||||||
|
with web_view.app.test_client() as client:
|
||||||
|
index_response = client.get("/conference/series")
|
||||||
|
detail_response = client.get("/conference/series/pycascades")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@ def check_conferences() -> None:
|
||||||
filepath = os.path.join(data_dir, "conferences.yaml")
|
filepath = os.path.join(data_dir, "conferences.yaml")
|
||||||
conferences_data = yaml.safe_load(open(filepath, "r"))
|
conferences_data = yaml.safe_load(open(filepath, "r"))
|
||||||
conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
|
conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
|
||||||
|
series = agenda.conference.load_series(data_dir)
|
||||||
|
|
||||||
prev_start = None
|
prev_start = None
|
||||||
prev_conf_data = None
|
prev_conf_data = None
|
||||||
|
|
@ -297,6 +298,11 @@ def check_conferences() -> None:
|
||||||
|
|
||||||
check_country_code(conf_data, "conference", required=False)
|
check_country_code(conf_data, "conference", required=False)
|
||||||
check_conference_dates(conf_data)
|
check_conference_dates(conf_data)
|
||||||
|
series_id = conf_data.get("series")
|
||||||
|
if series_id is not None and series_id not in series:
|
||||||
|
pprint(conf_data)
|
||||||
|
print(f"conference references unknown series {series_id!r}")
|
||||||
|
sys.exit(-1)
|
||||||
|
|
||||||
date_fields = agenda.conference.conference_date_fields(conf_data)
|
date_fields = agenda.conference.conference_date_fields(conf_data)
|
||||||
current_start = normalize_datetime(date_fields["sort_date"])
|
current_start = normalize_datetime(date_fields["sort_date"])
|
||||||
|
|
@ -316,6 +322,21 @@ def check_conferences() -> None:
|
||||||
print(len(conferences), "conferences")
|
print(len(conferences), "conferences")
|
||||||
|
|
||||||
|
|
||||||
|
def check_conference_series() -> None:
|
||||||
|
"""Check conference series metadata."""
|
||||||
|
series = agenda.conference.load_series(data_dir)
|
||||||
|
for series_id, item in series.items():
|
||||||
|
if not isinstance(series_id, str):
|
||||||
|
print(f"conference series id must be a string: {series_id!r}")
|
||||||
|
sys.exit(-1)
|
||||||
|
if "name" not in item:
|
||||||
|
pprint(item)
|
||||||
|
print(f"conference series {series_id!r} missing name")
|
||||||
|
sys.exit(-1)
|
||||||
|
check_country_code(item, "conference series", required=False)
|
||||||
|
print(len(series), "conference series")
|
||||||
|
|
||||||
|
|
||||||
def check_events() -> None:
|
def check_events() -> None:
|
||||||
"""Check events."""
|
"""Check events."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
@ -505,6 +526,7 @@ def check() -> None:
|
||||||
check_trains()
|
check_trains()
|
||||||
check_ferries()
|
check_ferries()
|
||||||
check_buses()
|
check_buses()
|
||||||
|
check_conference_series()
|
||||||
check_conferences()
|
check_conferences()
|
||||||
check_events()
|
check_events()
|
||||||
check_accommodation()
|
check_accommodation()
|
||||||
|
|
|
||||||
65
web_view.py
65
web_view.py
|
|
@ -372,6 +372,7 @@ def build_conference_list() -> list[StrDict]:
|
||||||
data_dir = app.config["PERSONAL_DATA"]
|
data_dir = app.config["PERSONAL_DATA"]
|
||||||
filepath = os.path.join(data_dir, "conferences.yaml")
|
filepath = os.path.join(data_dir, "conferences.yaml")
|
||||||
items: list[StrDict] = yaml.safe_load(open(filepath))
|
items: list[StrDict] = yaml.safe_load(open(filepath))
|
||||||
|
series_lookup = agenda.conference.load_series(data_dir)
|
||||||
conference_trip_lookup = {}
|
conference_trip_lookup = {}
|
||||||
|
|
||||||
for trip in agenda.trip.build_trip_list():
|
for trip in agenda.trip.build_trip_list():
|
||||||
|
|
@ -386,6 +387,10 @@ def build_conference_list() -> list[StrDict]:
|
||||||
if price:
|
if price:
|
||||||
conf["price"] = decimal.Decimal(price)
|
conf["price"] = decimal.Decimal(price)
|
||||||
|
|
||||||
|
series_id = conf.get("series")
|
||||||
|
if isinstance(series_id, str):
|
||||||
|
conf["series_detail"] = series_lookup.get(series_id)
|
||||||
|
|
||||||
if "start" in conf:
|
if "start" in conf:
|
||||||
key = (conf["start"], conf["name"])
|
key = (conf["start"], conf["name"])
|
||||||
if this_trip := conference_trip_lookup.get(key):
|
if this_trip := conference_trip_lookup.get(key):
|
||||||
|
|
@ -395,6 +400,34 @@ def build_conference_list() -> list[StrDict]:
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def build_conference_series_list() -> list[StrDict]:
|
||||||
|
"""Build conference series list with conference counts."""
|
||||||
|
data_dir = app.config["PERSONAL_DATA"]
|
||||||
|
series_lookup = agenda.conference.load_series(data_dir)
|
||||||
|
conferences = build_conference_list()
|
||||||
|
|
||||||
|
series_items: list[StrDict] = []
|
||||||
|
for series_id, series in series_lookup.items():
|
||||||
|
linked = [conf for conf in conferences if conf.get("series") == series_id]
|
||||||
|
latest = max((conf["sort_date"] for conf in linked), default=None)
|
||||||
|
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)
|
||||||
|
|
||||||
|
series_items.sort(key=lambda item: str(item["name"]).lower())
|
||||||
|
return series_items
|
||||||
|
|
||||||
|
|
||||||
def _conference_uid(conf: StrDict) -> str:
|
def _conference_uid(conf: StrDict) -> str:
|
||||||
"""Generate deterministic UID for conference events."""
|
"""Generate deterministic UID for conference events."""
|
||||||
start = agenda.utils.as_date(conf["start"])
|
start = agenda.utils.as_date(conf["start"])
|
||||||
|
|
@ -596,6 +629,38 @@ def past_conference_list() -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/conference/series")
|
||||||
|
def conference_series_list() -> str:
|
||||||
|
"""Page showing conference series."""
|
||||||
|
return flask.render_template(
|
||||||
|
"conference_series_list.html",
|
||||||
|
series_list=build_conference_series_list(),
|
||||||
|
get_country=agenda.get_country,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/conference/series/<series_id>")
|
||||||
|
def conference_series_page(series_id: str) -> str:
|
||||||
|
"""Page showing one conference series."""
|
||||||
|
series_lookup = agenda.conference.load_series(app.config["PERSONAL_DATA"])
|
||||||
|
series = series_lookup.get(series_id)
|
||||||
|
if series is None:
|
||||||
|
flask.abort(404)
|
||||||
|
|
||||||
|
conferences = [
|
||||||
|
conf for conf in build_conference_list() if conf.get("series") == series_id
|
||||||
|
]
|
||||||
|
return flask.render_template(
|
||||||
|
"conference_series.html",
|
||||||
|
series_id=series_id,
|
||||||
|
series=series,
|
||||||
|
conferences=conferences,
|
||||||
|
today=date.today(),
|
||||||
|
get_country=agenda.get_country,
|
||||||
|
fx_rate=agenda.fx.get_rates(app.config),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/conference/ical")
|
@app.route("/conference/ical")
|
||||||
def conference_ical() -> werkzeug.Response:
|
def conference_ical() -> werkzeug.Response:
|
||||||
"""Return all conferences as an iCalendar feed."""
|
"""Return all conferences as an iCalendar feed."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue