diff --git a/agenda/conference.py b/agenda/conference.py
index f9e15b4..89f43fe 100644
--- a/agenda/conference.py
+++ b/agenda/conference.py
@@ -2,6 +2,7 @@
import dataclasses
import decimal
+import os
import typing
from datetime import date, datetime
@@ -16,6 +17,18 @@ DATE_STATUSES = {"exact", "approximate", "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
class Conference:
"""Conference."""
@@ -23,6 +36,7 @@ class Conference:
name: str
topic: str
location: str
+ series: str | None = None
start: date | datetime | None = None
end: date | datetime | None = None
trip: date | None = None
@@ -166,6 +180,20 @@ def validate_conference_date_fields(item: StrDict) -> StrDict:
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]:
"""Read conferences from a YAML file and return a list of Event objects."""
events: list[Event] = []
diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md
index a538fff..08c391e 100644
--- a/docs/personal-data-yaml.md
+++ b/docs/personal-data-yaml.md
@@ -283,6 +283,7 @@ Legacy fields:
Common optional fields:
+- Series: `series`, a key from `conference_series.yaml`.
- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`.
- 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.
@@ -294,6 +295,7 @@ Exact example:
```yaml
- name: FOSDEM
+ series: fosdem
topic: FOSDEM
location: Brussels
country: be
@@ -319,6 +321,7 @@ Tentative example:
```yaml
- name: FOSDEM
+ series: fosdem
topic: FOSDEM
location: Brussels
country: be
@@ -335,6 +338,7 @@ Approximate examples:
```yaml
- name: Wikimedia Hackathon 2027
+ series: wikimedia-hackathon
topic: Wikimedia
location: Albania
country: al
@@ -346,6 +350,7 @@ Approximate examples:
hackathon: true
- name: PyCascades 2027
+ series: pycascades
topic: Python
location: TBC
dates:
@@ -355,6 +360,46 @@ Approximate examples:
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`
Top-level shape: list of people/entities.
diff --git a/templates/conference_list.html b/templates/conference_list.html
index 6529b22..e20b154 100644
--- a/templates/conference_list.html
+++ b/templates/conference_list.html
@@ -134,7 +134,7 @@ tr.conf-hl > td {
{% if item_list %}
{% set count = item_list | length %}
- | {{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }} |
+ {{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }} |
{% set ns = namespace(prev_month="") %}
{% for item in item_list %}
@@ -142,7 +142,7 @@ tr.conf-hl > td {
{% if month_label != ns.prev_month %}
{% set ns.prev_month = month_label %}
- | {{ month_label }} |
+ {{ month_label }} |
{% endif %}
@@ -176,6 +176,13 @@ tr.conf-hl > td {
{% endif %}
| {{ item.topic }} |
+
+ {% if item.series and item.series_detail %}
+ {{ item.series_detail.name }}
+ {% elif item.series %}
+ {{ item.series }}
+ {% endif %}
+ |
{% set country = get_country(item.country) if item.country else None %}
{% if country %}{{ country.flag }} {{ item.location }}
@@ -213,6 +220,7 @@ tr.conf-hl > td {
+
@@ -221,6 +229,7 @@ tr.conf-hl > td {
| Dates |
Conference |
Topic |
+ Series |
Location |
CFP ends |
Price |
diff --git a/templates/conference_series.html b/templates/conference_series.html
new file mode 100644
index 0000000..10bfdb7
--- /dev/null
+++ b/templates/conference_series.html
@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+
+{% block title %}{{ series.name }} - Edward Betts{% endblock %}
+
+{% block content %}
+
+
{{ series.name }}
+
+
+ {% if series.topic %}
+ - Topic
+ - {{ series.topic }}
+ {% endif %}
+ {% if series.usual_location or series.country %}
+ - Usual location
+ -
+ {% set country = get_country(series.country) if series.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ series.usual_location or "" }}
+
+ {% endif %}
+ {% if series.cadence %}
+ - Cadence
+ - {{ series.cadence }}
+ {% endif %}
+ {% if series.url %}
+ - Website
+ - {{ series.url }}
+ {% endif %}
+ {% if series.notes %}
+ - Notes
+ - {{ series.notes }}
+ {% endif %}
+
+
+
Editions
+
+
+
+ | Dates |
+ Conference |
+ Location |
+ Attendance |
+
+
+
+ {% for item in conferences %}
+
+ |
+ {{ item.display_date }}
+ {% if item.date_status == "tentative" %}
+ tentative
+ {% elif item.date_status == "approximate" %}
+ approximate
+ {% endif %}
+ |
+
+ {% if item.url %}{{ item.name }}
+ {% else %}{{ item.name }}{% endif %}
+ |
+
+ {% set country = get_country(item.country) if item.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ item.location }}
+ |
+
+ {% if item.going %}
+ going
+ {% endif %}
+ {% if item.registered %}
+ registered
+ {% endif %}
+ {% if item.linked_trip %}
+ {% set trip = item.linked_trip %}
+
+ trip{% if trip.title != item.name %}: {{ trip.title }}{% endif %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/conference_series_list.html b/templates/conference_series_list.html
new file mode 100644
index 0000000..a39890d
--- /dev/null
+++ b/templates/conference_series_list.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+
+{% block title %}Conference Series - Edward Betts{% endblock %}
+
+{% block content %}
+
+
Conference Series
+
+
+
+
+ | Series |
+ Topic |
+ Usual location |
+ Conferences |
+ Next |
+
+
+
+ {% for item in series_list %}
+
+ |
+ {{ item.name }}
+ {% if item.url %}
+ ↗
+ {% endif %}
+ |
+ {{ item.topic or "" }} |
+
+ {% set country = get_country(item.country) if item.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ item.usual_location or "" }}
+ |
+ {{ item.count }} |
+
+ {% if item.next_conf %}
+ {{ item.next_conf.display_date }} · {{ item.next_conf.name }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/navbar.html b/templates/navbar.html
index fe5b98a..373c053 100644
--- a/templates/navbar.html
+++ b/templates/navbar.html
@@ -28,6 +28,7 @@
{% set conference_pages = [
{"endpoint": "conference_list", "label": "Conferences" },
{"endpoint": "past_conference_list", "label": "Past conferences" },
+ {"endpoint": "conference_series_list", "label": "Conference series" },
] %}
@@ -63,7 +64,7 @@
- {% 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'] %}
Conferences
diff --git a/tests/test_conference_list.py b/tests/test_conference_list.py
index 7a09802..bd68c0d 100644
--- a/tests/test_conference_list.py
+++ b/tests/test_conference_list.py
@@ -2,6 +2,7 @@
from datetime import date
import typing
+from types import SimpleNamespace
import yaml
@@ -16,6 +17,7 @@ def test_build_conference_list_supports_inexact_dates(
conferences = [
{
"name": "PyCascades 2027",
+ "series": "pycascades",
"topic": "Python",
"location": "TBC",
"dates": {
@@ -40,6 +42,19 @@ def test_build_conference_list_supports_inexact_dates(
(tmp_path / "conferences.yaml").write_text(
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.setattr(agenda.trip, "build_trip_list", lambda: [])
@@ -53,3 +68,55 @@ def test_build_conference_list_supports_inexact_dates(
assert items[1]["date_status"] == "approximate"
assert items[1]["display_date"] == "March 2027"
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 detail_response.status_code == 200
+ assert b"trip: Seattle Python trip" in detail_response.data
diff --git a/validate_yaml.py b/validate_yaml.py
index 8baa314..9a41271 100755
--- a/validate_yaml.py
+++ b/validate_yaml.py
@@ -283,6 +283,7 @@ def check_conferences() -> None:
filepath = os.path.join(data_dir, "conferences.yaml")
conferences_data = yaml.safe_load(open(filepath, "r"))
conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
+ series = agenda.conference.load_series(data_dir)
prev_start = None
prev_conf_data = None
@@ -297,6 +298,11 @@ def check_conferences() -> None:
check_country_code(conf_data, "conference", required=False)
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)
current_start = normalize_datetime(date_fields["sort_date"])
@@ -316,6 +322,21 @@ def check_conferences() -> None:
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:
"""Check events."""
today = date.today()
@@ -505,6 +526,7 @@ def check() -> None:
check_trains()
check_ferries()
check_buses()
+ check_conference_series()
check_conferences()
check_events()
check_accommodation()
diff --git a/web_view.py b/web_view.py
index e4981fa..534fb2b 100755
--- a/web_view.py
+++ b/web_view.py
@@ -372,6 +372,7 @@ def build_conference_list() -> list[StrDict]:
data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml")
items: list[StrDict] = yaml.safe_load(open(filepath))
+ series_lookup = agenda.conference.load_series(data_dir)
conference_trip_lookup = {}
for trip in agenda.trip.build_trip_list():
@@ -386,6 +387,10 @@ def build_conference_list() -> list[StrDict]:
if 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:
key = (conf["start"], conf["name"])
if this_trip := conference_trip_lookup.get(key):
@@ -395,6 +400,32 @@ def build_conference_list() -> list[StrDict]:
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
+ )
+ item: StrDict = {
+ "id": series_id,
+ **series,
+ "count": len(linked),
+ "latest": latest,
+ "next_conf": next_conf,
+ }
+ series_items.append(item)
+
+ series_items.sort(key=lambda item: str(item["name"]).lower())
+ return series_items
+
+
def _conference_uid(conf: StrDict) -> str:
"""Generate deterministic UID for conference events."""
start = agenda.utils.as_date(conf["start"])
@@ -596,6 +627,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/")
+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")
def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed."""