Add conference series pages
This commit is contained in:
parent
098c7e4447
commit
57b2db205d
9 changed files with 367 additions and 3 deletions
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ tr.conf-hl > td {
|
|||
{% if item_list %}
|
||||
{% set count = item_list | length %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
<tr class="conf-month-row">
|
||||
<td colspan="6">{{ month_label }}</td>
|
||||
<td colspan="7">{{ month_label }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</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">
|
||||
{% 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 {
|
|||
<col>
|
||||
<col style="width: 18rem">
|
||||
<col style="width: 14rem">
|
||||
<col style="width: 14rem">
|
||||
<col style="width: 7rem">
|
||||
<col style="width: 10rem">
|
||||
</colgroup>
|
||||
|
|
@ -221,6 +229,7 @@ tr.conf-hl > td {
|
|||
<th>Dates</th>
|
||||
<th>Conference</th>
|
||||
<th>Topic</th>
|
||||
<th>Series</th>
|
||||
<th>Location</th>
|
||||
<th>CFP ends</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 %}
|
||||
44
templates/conference_series_list.html
Normal file
44
templates/conference_series_list.html
Normal 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 %}
|
||||
|
|
@ -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 @@
|
|||
</li>
|
||||
|
||||
<!-- 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">
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
63
web_view.py
63
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/<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")
|
||||
def conference_ical() -> werkzeug.Response:
|
||||
"""Return all conferences as an iCalendar feed."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue