Add conference series pages

This commit is contained in:
Edward Betts 2026-06-22 10:25:55 +01:00
parent 098c7e4447
commit 57b2db205d
9 changed files with 367 additions and 3 deletions

View file

@ -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] = []

View file

@ -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.

View file

@ -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>

View 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 %}

View file

@ -0,0 +1,44 @@
{% 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.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 %}

View file

@ -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

View file

@ -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,55 @@ 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 detail_response.status_code == 200
assert b"trip: Seattle Python trip" in detail_response.data

View file

@ -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()

View file

@ -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,32 @@ 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
)
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: 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 +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/<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."""