Compare commits
3 commits
fc82cf280a
...
098c7e4447
| Author | SHA1 | Date | |
|---|---|---|---|
| 098c7e4447 | |||
| 14f5baf77c | |||
| 7ec36a5e80 |
9 changed files with 1240 additions and 53 deletions
|
|
@ -16,6 +16,8 @@ import pycountry
|
|||
import requests
|
||||
import yaml
|
||||
|
||||
from agenda.conference import conference_date_fields
|
||||
|
||||
USER_AGENT = "add-new-conference/0.1"
|
||||
COORDINATE_PATTERNS = (
|
||||
re.compile(r"@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)"),
|
||||
|
|
@ -160,7 +162,9 @@ def webpage_to_text(root: lxml.html.HtmlElement) -> str:
|
|||
text_maker = html2text.HTML2Text()
|
||||
text_maker.ignore_links = True
|
||||
text_maker.ignore_images = True
|
||||
return text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode"))
|
||||
return typing.cast(
|
||||
str, text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode"))
|
||||
)
|
||||
|
||||
|
||||
def parse_osm_url(url: str) -> tuple[float, float] | None:
|
||||
|
|
@ -267,13 +271,13 @@ def insert_sorted(
|
|||
) -> list[dict[str, typing.Any]]:
|
||||
"""Insert a conference sorted by start date and skip duplicate URLs."""
|
||||
new_url = new_conf.get("url")
|
||||
new_start = parse_date(str(new_conf["start"]))
|
||||
new_start = conference_sort_datetime(new_conf)
|
||||
new_year = new_start.year
|
||||
|
||||
if new_url:
|
||||
for conf in conferences:
|
||||
if conf.get("url") == new_url:
|
||||
existing_start = parse_date(str(conf["start"]))
|
||||
existing_start = conference_sort_datetime(conf)
|
||||
existing_year = existing_start.year
|
||||
|
||||
if url_has_year_component(new_url):
|
||||
|
|
@ -287,7 +291,7 @@ def insert_sorted(
|
|||
return conferences
|
||||
|
||||
for idx, conf in enumerate(conferences):
|
||||
existing_start = parse_date(str(conf["start"]))
|
||||
existing_start = conference_sort_datetime(conf)
|
||||
if new_start < existing_start:
|
||||
conferences.insert(idx, new_conf)
|
||||
return conferences
|
||||
|
|
@ -295,6 +299,14 @@ def insert_sorted(
|
|||
return conferences
|
||||
|
||||
|
||||
def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
|
||||
"""Return conference sort date as a datetime."""
|
||||
sort_date = conference_date_fields(conf)["sort_date"]
|
||||
if isinstance(sort_date, datetime):
|
||||
return sort_date
|
||||
return datetime.combine(sort_date, time())
|
||||
|
||||
|
||||
def validate_country(conf: dict[str, typing.Any]) -> None:
|
||||
"""Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible."""
|
||||
country = conf.get("country")
|
||||
|
|
@ -377,7 +389,11 @@ def maybe_extract_explicit_end_time(source_text: str) -> int | None:
|
|||
|
||||
def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> None:
|
||||
"""Ensure an end value exists, with a Geomob-specific fallback."""
|
||||
start_value = new_conf.get("start")
|
||||
dates = new_conf.get("dates")
|
||||
nested_dates = dates if isinstance(dates, dict) else None
|
||||
start_value = (
|
||||
nested_dates.get("start") if nested_dates is not None else new_conf.get("start")
|
||||
)
|
||||
if start_value is None:
|
||||
return
|
||||
|
||||
|
|
@ -395,9 +411,16 @@ def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> No
|
|||
end_hour = 22
|
||||
|
||||
geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0)
|
||||
new_conf["end"] = same_type_as_start(
|
||||
start_value, geomob_end, prefer_datetime=True
|
||||
)
|
||||
end_value = same_type_as_start(start_value, geomob_end, prefer_datetime=True)
|
||||
if nested_dates is not None:
|
||||
nested_dates["end"] = end_value
|
||||
else:
|
||||
new_conf["end"] = end_value
|
||||
return
|
||||
|
||||
if nested_dates is not None:
|
||||
if "end" not in nested_dates:
|
||||
nested_dates["end"] = same_type_as_start(start_value, start_dt)
|
||||
return
|
||||
|
||||
if "end" not in new_conf:
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@
|
|||
|
||||
import dataclasses
|
||||
import decimal
|
||||
import typing
|
||||
from datetime import date, datetime
|
||||
|
||||
import yaml
|
||||
|
||||
from . import utils
|
||||
from .event import Event
|
||||
from .types import DateOrDateTime, StrDict
|
||||
|
||||
MAX_CONF_DAYS = 20
|
||||
DATE_STATUSES = {"exact", "approximate", "tentative"}
|
||||
DATED_STATUSES = {"exact", "tentative"}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
|
@ -19,8 +23,8 @@ class Conference:
|
|||
name: str
|
||||
topic: str
|
||||
location: str
|
||||
start: date | datetime
|
||||
end: date | datetime
|
||||
start: date | datetime | None = None
|
||||
end: date | datetime | None = None
|
||||
trip: date | None = None
|
||||
country: str | None = None
|
||||
venue: str | None = None
|
||||
|
|
@ -46,6 +50,7 @@ class Conference:
|
|||
attendees: int | None = None
|
||||
hashtag: str | None = None
|
||||
description: str | None = None
|
||||
dates: StrDict | None = None
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
|
|
@ -57,13 +62,126 @@ class Conference:
|
|||
)
|
||||
|
||||
|
||||
def _date_range_label(start: date, end: date) -> str:
|
||||
"""Format conference date range for display."""
|
||||
if start == end:
|
||||
return start.strftime("%a %-d %b %Y")
|
||||
if start.year == end.year and start.month == end.month:
|
||||
return f"{start.strftime('%a %-d')}-{end.strftime('%-d %b %Y')}"
|
||||
return f"{start.strftime('%a %-d %b')}-{end.strftime('%-d %b %Y')}"
|
||||
|
||||
|
||||
def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime:
|
||||
"""Return date-like field value or raise ValueError."""
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value
|
||||
raise ValueError(f"conference dates field {field_name!r} must be a date/datetime")
|
||||
|
||||
|
||||
def _require_date_only(value: typing.Any, field_name: str) -> date:
|
||||
"""Return field value as a date or raise ValueError."""
|
||||
return typing.cast(date, utils.as_date(_require_date_value(value, field_name)))
|
||||
|
||||
|
||||
def conference_date_fields(item: StrDict) -> StrDict:
|
||||
"""Return derived date fields for a conference YAML item."""
|
||||
raw_dates = item.get("dates")
|
||||
if raw_dates is None:
|
||||
status = typing.cast(str, item.get("date_status", "exact"))
|
||||
if status not in DATE_STATUSES:
|
||||
raise ValueError(f"unknown conference date status {status!r}")
|
||||
start = _require_date_value(item.get("start"), "start")
|
||||
end = _require_date_value(item.get("end", start), "end")
|
||||
start_date = utils.as_date(start)
|
||||
end_date = utils.as_date(end)
|
||||
return {
|
||||
"date_status": status,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"sort_date": start_date,
|
||||
"latest_date": end_date,
|
||||
"display_date": _date_range_label(start_date, end_date),
|
||||
"has_exact_dates": status == "exact",
|
||||
}
|
||||
|
||||
if not isinstance(raw_dates, dict):
|
||||
raise ValueError("conference dates must be a mapping")
|
||||
|
||||
status_value = raw_dates.get("status", "exact")
|
||||
if not isinstance(status_value, str) or status_value not in DATE_STATUSES:
|
||||
raise ValueError(f"unknown conference date status {status_value!r}")
|
||||
|
||||
if status_value in DATED_STATUSES:
|
||||
start = _require_date_value(raw_dates.get("start", item.get("start")), "start")
|
||||
end = _require_date_value(raw_dates.get("end", item.get("end", start)), "end")
|
||||
start_date = utils.as_date(start)
|
||||
end_date = utils.as_date(end)
|
||||
label = raw_dates.get("label")
|
||||
display_date = (
|
||||
label if isinstance(label, str) else _date_range_label(start_date, end_date)
|
||||
)
|
||||
return {
|
||||
"date_status": status_value,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"sort_date": start_date,
|
||||
"latest_date": end_date,
|
||||
"display_date": display_date,
|
||||
"has_exact_dates": status_value == "exact",
|
||||
}
|
||||
|
||||
earliest = _require_date_only(raw_dates.get("earliest"), "earliest")
|
||||
latest = _require_date_only(raw_dates.get("latest"), "latest")
|
||||
label = raw_dates.get("label")
|
||||
display_date = (
|
||||
label if isinstance(label, str) else _date_range_label(earliest, latest)
|
||||
)
|
||||
return {
|
||||
"date_status": status_value,
|
||||
"start_date": earliest,
|
||||
"end_date": latest,
|
||||
"sort_date": earliest,
|
||||
"latest_date": latest,
|
||||
"display_date": display_date,
|
||||
"has_exact_dates": False,
|
||||
}
|
||||
|
||||
|
||||
def validate_conference_date_fields(item: StrDict) -> StrDict:
|
||||
"""Validate conference date fields and return derived values."""
|
||||
fields = conference_date_fields(item)
|
||||
if fields["start_date"] > fields["end_date"]:
|
||||
raise ValueError("conference ends before it starts")
|
||||
|
||||
if fields["date_status"] in DATED_STATUSES:
|
||||
duration = (fields["end_date"] - fields["start_date"]).days
|
||||
if duration >= MAX_CONF_DAYS:
|
||||
raise ValueError(
|
||||
f"conference is {duration} days; maximum is {MAX_CONF_DAYS - 1}"
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def get_list(filepath: str) -> list[Event]:
|
||||
"""Read conferences from a YAML file and return a list of Event objects."""
|
||||
events: list[Event] = []
|
||||
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))):
|
||||
assert conf.start <= conf.end
|
||||
duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days
|
||||
assert duration < MAX_CONF_DAYS
|
||||
for item in yaml.safe_load(open(filepath, "r")):
|
||||
try:
|
||||
fields = validate_conference_date_fields(item)
|
||||
except ValueError as exc:
|
||||
raise AssertionError(str(exc)) from exc
|
||||
normalized_item = dict(item)
|
||||
if "start" in fields:
|
||||
normalized_item["start"] = fields["start"]
|
||||
normalized_item["end"] = fields["end"]
|
||||
conf = Conference(**normalized_item)
|
||||
|
||||
if fields["has_exact_dates"]:
|
||||
assert conf.start is not None and conf.end is not None
|
||||
event = Event(
|
||||
name="conference",
|
||||
date=conf.start,
|
||||
|
|
|
|||
779
docs/personal-data-yaml.md
Normal file
779
docs/personal-data-yaml.md
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
# Personal Data YAML Formats
|
||||
|
||||
This document describes the YAML files read from `../personal-data/`. It is intended for humans and LLMs generating new entries.
|
||||
|
||||
## General Rules
|
||||
|
||||
- Use YAML lists for most files. `airports.yaml` is a mapping keyed by IATA code.
|
||||
- Use ISO-like YAML dates and datetimes:
|
||||
- Date: `2026-03-14`
|
||||
- Datetime with timezone: `2026-03-14 09:30:00+01:00`
|
||||
- Use lowercase ISO 3166-1 alpha-2 country codes, for example `gb`, `be`, `us`.
|
||||
- Use quoted strings for prices and identifiers that might otherwise be parsed as numbers: `'154.34'`, `'06525269'`, `'0042'`.
|
||||
- Currencies must be in `config.CURRENCIES` or `GBP`.
|
||||
- Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries.
|
||||
- Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation.
|
||||
- Coordinates are `latitude` then `longitude`, both numeric.
|
||||
|
||||
## Cross-File References
|
||||
|
||||
- `flights.yaml` flight `airline` values reference `airlines.yaml` `iata`.
|
||||
- `flights.yaml` flight `from` and `to` values reference `airports.yaml` keys.
|
||||
- `trains.yaml` journey and leg `from` and `to` values reference `stations.yaml` `name`.
|
||||
- `ferries.yaml` `from` and `to` values reference `ferry_terminals.yaml` `name`.
|
||||
- `buses.yaml` `from` and `to` values reference `bus_stops.yaml` `name`.
|
||||
- `coaches.yaml` `from` and `to` values reference `coach_stations.yaml` `name`.
|
||||
- Station, stop, and terminal `routes` values name GeoJSON files without the `.geojson` extension.
|
||||
|
||||
## `accommodation.yaml`
|
||||
|
||||
Top-level shape: list of accommodation stays.
|
||||
|
||||
Used by: agenda events, trip pages, trip maps, busy/location logic.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `type`: accommodation category such as `hotel`, `apartment`, `airbnb`.
|
||||
- `name`: property name.
|
||||
- `country`: lowercase country code.
|
||||
- `location`: city or place name.
|
||||
- `trip`: trip start date.
|
||||
- `from`: check-in datetime.
|
||||
- `to`: check-out datetime.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- Booking: `operator`, `booking_reference`, `confirmation_code`, `booking_url`, `url`, `email`, `phone`.
|
||||
- Money: `price`, `currency`, `room_rate`, `estimated_taxes`, `estimated_additional_fees`.
|
||||
- Room/stay: `address`, `room_type`, `room_name`, `room_number`, `number_of_adults`, `breakfast_included`, `breakfast`, `cancellation_policy`, `free_cancellation`, `refundable`.
|
||||
- Coordinates/IDs: `latitude`, `longitude`, `timezone`, `osm_node`, `wikidata`.
|
||||
- Loyalty: `rewards`, `radisson_rewards_number`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- type: hotel
|
||||
operator: Example Hotels
|
||||
name: Example Central Hotel
|
||||
location: Brussels
|
||||
country: be
|
||||
trip: 2026-02-06
|
||||
from: 2026-02-06 15:00:00+01:00
|
||||
to: 2026-02-09 11:00:00+01:00
|
||||
address: 1 Example Street, Brussels
|
||||
confirmation_code: ABC123
|
||||
price: '312.50'
|
||||
currency: EUR
|
||||
number_of_adults: 1
|
||||
room_type: Standard double
|
||||
breakfast_included: true
|
||||
latitude: 50.8466
|
||||
longitude: 4.3528
|
||||
```
|
||||
|
||||
## `airlines.yaml`
|
||||
|
||||
Top-level shape: list of airlines.
|
||||
|
||||
Used by: flight loading and display.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `iata`: two-character IATA airline code.
|
||||
- `icao`: three-character ICAO airline code.
|
||||
- `name`: airline name.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `flight_number_prefer_icao`: boolean. When true, display flight numbers with the ICAO code instead of the IATA code.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- iata: BA
|
||||
icao: BAW
|
||||
name: British Airways
|
||||
- iata: U2
|
||||
icao: EZY
|
||||
name: easyJet
|
||||
flight_number_prefer_icao: true
|
||||
```
|
||||
|
||||
## `airports.yaml`
|
||||
|
||||
Top-level shape: mapping keyed by IATA airport code.
|
||||
|
||||
Used by: flight loading, distance calculation, maps, unbooked route hints.
|
||||
|
||||
Required fields for each airport:
|
||||
|
||||
- `iata`: IATA code. Should match the mapping key.
|
||||
- `name`: airport name.
|
||||
- `city`: city or main served place.
|
||||
- `country`: lowercase country code.
|
||||
- `latitude`, `longitude`: numeric coordinates.
|
||||
- `qid`: Wikidata QID.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `alt_name`: display name override used in labels.
|
||||
- `elevation`: metres.
|
||||
- `website`, `url`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
BRU:
|
||||
iata: BRU
|
||||
name: Brussels Airport
|
||||
city: Brussels
|
||||
country: be
|
||||
qid: Q220613
|
||||
latitude: 50.9014
|
||||
longitude: 4.4844
|
||||
elevation: 56
|
||||
website: https://www.brusselsairport.be/
|
||||
```
|
||||
|
||||
## `bus_stops.yaml`
|
||||
|
||||
Top-level shape: list of bus stops.
|
||||
|
||||
Used by: bus trip loading, maps, route rendering.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: stop name referenced by `buses.yaml`.
|
||||
- `city`: city or place.
|
||||
- `country`: lowercase country code.
|
||||
- `latitude`, `longitude`: numeric coordinates.
|
||||
- `routes`: mapping from destination stop name to GeoJSON filename without `.geojson`.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `Atco`: UK ATCO stop code.
|
||||
- `osm_node`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: West Street
|
||||
city: Bristol
|
||||
country: gb
|
||||
Atco: '0100BRA10073'
|
||||
osm_node: 485403178
|
||||
latitude: 51.4393854
|
||||
longitude: -2.6017977
|
||||
routes:
|
||||
Bristol Airport: West_Street_to_Bristol_Airport
|
||||
```
|
||||
|
||||
## `buses.yaml`
|
||||
|
||||
Top-level shape: list of bus journeys.
|
||||
|
||||
Used by: trip loading, maps, trip timeline. Bus journeys are not counted for Schengen tracking.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `trip`: trip start date.
|
||||
- `depart`: departure datetime.
|
||||
- `arrive`: arrival datetime. `validate_yaml.py` requires arrival after departure and duration no more than 12 hours.
|
||||
- `from`, `to`: names from `bus_stops.yaml`.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `operator`, `price`, `currency`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- trip: 2026-03-14
|
||||
depart: 2026-03-14 08:20:00+00:00
|
||||
arrive: 2026-03-14 08:55:00+00:00
|
||||
from: West Street
|
||||
to: Bristol Airport
|
||||
operator: First Bus
|
||||
price: '2.00'
|
||||
currency: GBP
|
||||
```
|
||||
|
||||
## `coach_stations.yaml`
|
||||
|
||||
Top-level shape: list of coach stations.
|
||||
|
||||
Used by: coach trip loading, maps, route rendering.
|
||||
|
||||
Fields are the same pattern as `bus_stops.yaml`, except entries describe coach stations.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: Example Coach Station
|
||||
city: Example City
|
||||
country: gb
|
||||
latitude: 51.4500
|
||||
longitude: -2.5800
|
||||
routes:
|
||||
Other Coach Station: example_city_to_other_city
|
||||
```
|
||||
|
||||
## `coaches.yaml`
|
||||
|
||||
Top-level shape: list of coach journeys.
|
||||
|
||||
Used by: trip loading, maps, trip timeline. Coach journeys are not counted for Schengen tracking.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `trip`, `depart`, `arrive`, `from`, `to`.
|
||||
- `from` and `to` must be names from `coach_stations.yaml`.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `operator`, `class`, `booking_reference`, `price`, `currency`, `price_details`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- booking_reference: ABC123
|
||||
trip: 2026-05-25
|
||||
price: '55.00'
|
||||
currency: GBP
|
||||
depart: 2026-05-26 14:45:00+01:00
|
||||
arrive: 2026-05-26 18:30:00+01:00
|
||||
from: Example Coach Station
|
||||
to: Other Coach Station
|
||||
operator: Example Coaches
|
||||
class: Standard
|
||||
price_details:
|
||||
base_fare: '55.00'
|
||||
```
|
||||
|
||||
## `conferences.yaml`
|
||||
|
||||
Top-level shape: list of conferences and conference-like events.
|
||||
|
||||
Used by: agenda events, trip pages, trip maps, conference list, CFP reminders.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: event name.
|
||||
- `topic`: topic/category.
|
||||
- `location`: city or location label.
|
||||
- Date information, either as legacy top-level `start` and `end`, or preferred nested `dates`.
|
||||
|
||||
Preferred `dates` fields:
|
||||
|
||||
- `status`: one of `exact`, `tentative`, or `approximate`.
|
||||
- For `exact` and `tentative`: `start` and `end` dates/datetimes. `end` must be no earlier than `start`, and duration must be under 20 days.
|
||||
- For `approximate`: `earliest` and `latest` dates for sorting/past-future filtering.
|
||||
- `label`: optional human-readable date text. Recommended for `tentative` and `approximate`, for example `likely first weekend of February 2027` or `March 2027`.
|
||||
- `basis`: optional explanation of why a tentative date is expected.
|
||||
|
||||
Date status behavior:
|
||||
|
||||
- `exact`: confirmed dates. These create agenda events, iCalendar entries, and timeline bars.
|
||||
- `tentative`: guessed or unconfirmed exact dates. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars.
|
||||
- `approximate`: only a broad date range is known. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars.
|
||||
|
||||
Legacy fields:
|
||||
|
||||
- Existing top-level `start` and `end` are still supported and are treated as `exact` unless `date_status` says otherwise.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- 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.
|
||||
- Web/CFP: `url`, `cfp_end`, `cfp_url`, `hashtag`, `description`.
|
||||
- Money/tickets: `free`, `price`, `currency`, `ticket_type`.
|
||||
- Other flags: `hackathon`, `attendees`.
|
||||
|
||||
Exact example:
|
||||
|
||||
```yaml
|
||||
- name: FOSDEM
|
||||
topic: FOSDEM
|
||||
location: Brussels
|
||||
country: be
|
||||
trip: 2026-02-06
|
||||
dates:
|
||||
status: exact
|
||||
start: 2026-02-07
|
||||
end: 2026-02-08
|
||||
attend_start: 2026-02-07 14:00:00+01:00
|
||||
attend_end: 2026-02-08
|
||||
going: true
|
||||
registered: true
|
||||
accommodation_booked: true
|
||||
transport_booked: true
|
||||
url: https://fosdem.org/2026/
|
||||
venue: Universite Libre de Bruxelles
|
||||
address: Av. Franklin Roosevelt 50, 1050 Bruxelles, Belgium
|
||||
latitude: 50.8132
|
||||
longitude: 4.3822
|
||||
```
|
||||
|
||||
Tentative example:
|
||||
|
||||
```yaml
|
||||
- name: FOSDEM
|
||||
topic: FOSDEM
|
||||
location: Brussels
|
||||
country: be
|
||||
dates:
|
||||
status: tentative
|
||||
start: 2027-01-30
|
||||
end: 2027-01-31
|
||||
label: likely first weekend of February 2027
|
||||
basis: FOSDEM is usually on the weekend where Sunday is the first Sunday in February
|
||||
url: https://fosdem.org/2027/
|
||||
```
|
||||
|
||||
Approximate examples:
|
||||
|
||||
```yaml
|
||||
- name: Wikimedia Hackathon 2027
|
||||
topic: Wikimedia
|
||||
location: Albania
|
||||
country: al
|
||||
dates:
|
||||
status: approximate
|
||||
label: mid-April 2027
|
||||
earliest: 2027-04-11
|
||||
latest: 2027-04-20
|
||||
hackathon: true
|
||||
|
||||
- name: PyCascades 2027
|
||||
topic: Python
|
||||
location: TBC
|
||||
dates:
|
||||
status: approximate
|
||||
label: March 2027
|
||||
earliest: 2027-03-01
|
||||
latest: 2027-03-31
|
||||
```
|
||||
|
||||
## `entities.yaml`
|
||||
|
||||
Top-level shape: list of people/entities.
|
||||
|
||||
Used by: birthday events.
|
||||
|
||||
Required fields for birthday support:
|
||||
|
||||
- `name`: full name.
|
||||
- `label`: display name.
|
||||
- `type`: for example `human`.
|
||||
- `birthday`: mapping with `day`, `month`, and optionally `year`.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `relation`, `email`.
|
||||
|
||||
If `birthday.year` is omitted, age is shown as unknown.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: Ada Example
|
||||
label: Ada
|
||||
type: human
|
||||
relation: friend
|
||||
birthday:
|
||||
day: 10
|
||||
month: 12
|
||||
year: 1990
|
||||
```
|
||||
|
||||
## `events.yaml`
|
||||
|
||||
Top-level shape: list of general events.
|
||||
|
||||
Used by: agenda events and trip pages.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: event type.
|
||||
- One date source:
|
||||
- `date`: single event date/datetime, or
|
||||
- `start_date`: used for events with a separate `end_date`, or
|
||||
- `rrule`: recurrence rule string.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `title`: display title.
|
||||
- `end_date`: explicit end date/datetime.
|
||||
- `duration`: ISO 8601 duration such as `PT2H`, `P1D`.
|
||||
- `url`.
|
||||
- Trip/map fields: `trip`, `location`, `country`, `venue`, `address`, `latitude`, `longitude`.
|
||||
|
||||
Special cases:
|
||||
|
||||
- For `name: travel_insurance`, the event date field is `end_date`; no `end_date` is attached to the generated event.
|
||||
- For recurring events, if the `rrule` has no `BYHOUR`, `BYMINUTE`, or `BYSECOND`, generated events are all-day dates. Otherwise generated datetimes are localized to UK time.
|
||||
- `skip_trips=True` consumers ignore entries with `trip`.
|
||||
|
||||
Examples:
|
||||
|
||||
```yaml
|
||||
- name: travel_insurance
|
||||
start_date: 2026-05-04
|
||||
end_date: 2027-05-03
|
||||
|
||||
- name: meetup
|
||||
title: Example Geo Meetup
|
||||
date: 2026-06-18 18:30:00+01:00
|
||||
duration: PT2H
|
||||
url: https://example.org/meetup
|
||||
location: Bristol
|
||||
country: gb
|
||||
latitude: 51.4545
|
||||
longitude: -2.5879
|
||||
|
||||
- name: market
|
||||
title: Monthly Example Market
|
||||
rrule: FREQ=MONTHLY;BYDAY=1SA
|
||||
```
|
||||
|
||||
## `ferries.yaml`
|
||||
|
||||
Top-level shape: list of ferry journeys.
|
||||
|
||||
Used by: trip loading, maps, trip timeline, Schengen tracking.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `trip`: trip start date.
|
||||
- `depart`, `arrive`: datetimes. Ferry `arrive` is required.
|
||||
- `from`, `to`: names from `ferry_terminals.yaml`.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- `operator`, `ferry`, `direction`, `class`, `booking_reference`, `price`, `currency`.
|
||||
- `price_details`: free-form mapping of fare components.
|
||||
- `vehicle`: mapping with fields such as `type`, `registration`, `height`, `length`, `extras`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- booking_reference: ABC123
|
||||
trip: 2026-05-04
|
||||
price: '302.00'
|
||||
currency: GBP
|
||||
depart: 2026-05-04 23:00:00+01:00
|
||||
arrive: 2026-05-05 08:00:00+02:00
|
||||
from: Portsmouth
|
||||
to: Cherbourg
|
||||
operator: Brittany Ferries
|
||||
class: Commodore cabin
|
||||
price_details:
|
||||
base_fare: '153.00'
|
||||
cabin: '149.00'
|
||||
vehicle:
|
||||
type: Example car
|
||||
registration: AB12CDE
|
||||
height: 1.63m
|
||||
length: 4.15m
|
||||
```
|
||||
|
||||
## `ferry_terminals.yaml`
|
||||
|
||||
Top-level shape: list of ferry terminals.
|
||||
|
||||
Used by: ferry loading and route rendering.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: terminal name referenced by `ferries.yaml`.
|
||||
- `city`, `country`.
|
||||
- `latitude`, `longitude`.
|
||||
- `routes`: mapping from destination terminal name to GeoJSON filename without `.geojson`. Ferry route rendering expects a GeoJSON route.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `osm_node`, `osm_way`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: Portsmouth
|
||||
city: Portsmouth
|
||||
country: gb
|
||||
osm_way: 123456
|
||||
latitude: 50.8120
|
||||
longitude: -1.0880
|
||||
routes:
|
||||
Cherbourg: portsmouth_cherbourg
|
||||
```
|
||||
|
||||
## `flight_destinations.yaml`
|
||||
|
||||
Top-level shape: list of origin rules for unbooked conference flight route hints.
|
||||
|
||||
Used by: trip maps when a trip has conferences but no booked travel.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `origin`: origin airport IATA code.
|
||||
- `airline`: airline IATA code. Currently loaded for validation/description but not used in origin selection.
|
||||
- `destinations`: list of destination airport IATA codes.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- origin: BRS
|
||||
airline: U2
|
||||
destinations:
|
||||
- AMS
|
||||
- BCN
|
||||
- CDG
|
||||
```
|
||||
|
||||
## `flights.yaml`
|
||||
|
||||
Top-level shape: list of flight bookings. Each booking contains one or more flight legs.
|
||||
|
||||
Used by: agenda transport events, trip loading, maps, distance calculation.
|
||||
|
||||
Required booking fields:
|
||||
|
||||
- `trip`: trip start date.
|
||||
- `flights`: list of flight leg mappings.
|
||||
|
||||
Common optional booking fields:
|
||||
|
||||
- `booking_reference`, `price`, `currency`.
|
||||
|
||||
Required flight leg fields:
|
||||
|
||||
- `depart`: departure datetime.
|
||||
- `from`, `to`: airport IATA codes from `airports.yaml`.
|
||||
- `flight_number`: numeric/string flight number without airline prefix.
|
||||
- `airline`: airline IATA code from `airlines.yaml`.
|
||||
|
||||
Common optional flight leg fields:
|
||||
|
||||
- Time/location: `arrive`, `from_terminal`, `to_terminal`, `duration`.
|
||||
- Seat/cabin: `seat`, `seat_type`, `class`, `cabin`.
|
||||
- Aircraft: `plane`, `registration`.
|
||||
- Tracking: `distance`, `co2_kg`, `openflights_trip`, `reason`.
|
||||
- Ticket/passenger: `e_ticket_number`, `ticket_number`, `frequent_flyer_number`, `passenger_name`, `passengers`, `baggage`, `payment_details`.
|
||||
|
||||
`validate_yaml.py` checks that every booking has `trip`, all flight airlines exist in `airlines.yaml`, bookings are sorted by first departure, and currencies are configured. It reports flights missing `co2_kg`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- booking_reference: ABC123
|
||||
trip: 2026-04-22
|
||||
price: '62.50'
|
||||
currency: GBP
|
||||
flights:
|
||||
- depart: 2026-04-22 17:20:00+01:00
|
||||
arrive: 2026-04-22 20:20:00+02:00
|
||||
from: LHR
|
||||
to: BRU
|
||||
flight_number: '1234'
|
||||
airline: BA
|
||||
duration: 01:00
|
||||
seat: 5F
|
||||
seat_type: W
|
||||
class: C
|
||||
cabin: business
|
||||
plane: Airbus A320
|
||||
registration: G-ABCD
|
||||
co2_kg: 154
|
||||
```
|
||||
|
||||
## `follow_launches.yaml`
|
||||
|
||||
Top-level shape: list of SpaceDevs launch slugs.
|
||||
|
||||
Used by: no current in-repo reader was found, but the file appears intended as a watch list for launch update tooling.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- starship-integrated-flight-test-5
|
||||
- artemis-ii
|
||||
```
|
||||
|
||||
## `stations.yaml`
|
||||
|
||||
Top-level shape: list of railway stations.
|
||||
|
||||
Used by: train loading, maps, route rendering.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: station name referenced by `trains.yaml`.
|
||||
- `country`: lowercase country code.
|
||||
- `latitude`, `longitude`.
|
||||
- `routes`: mapping from destination station name to GeoJSON filename without `.geojson`.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- `uic`, `alpha3`, `wikidata`, `osm_node`.
|
||||
|
||||
Note: the code reads `routes`, not `rotues`; `rotues` appears to be a typo in existing data and should not be used for new entries.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: London St Pancras
|
||||
uic: 7015400
|
||||
alpha3: STP
|
||||
wikidata: Q720102
|
||||
latitude: 51.531921
|
||||
longitude: -0.126361
|
||||
country: gb
|
||||
routes:
|
||||
Brussels Midi: london_brussels_eurostar
|
||||
```
|
||||
|
||||
## `subscriptions.yaml`
|
||||
|
||||
Top-level shape: list of subscriptions.
|
||||
|
||||
Used by: subscription renewal agenda events when `renewal_date` is present.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `name`: subscription name.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
- Dates: `start`, `start_date`, `renewal_date`.
|
||||
- `price`: mapping with `amount` and `currency`.
|
||||
- `term`: mapping with `duration` and `unit` or `term_unit`.
|
||||
- Account: `email`, `account_url`, `account_number`.
|
||||
|
||||
Only items with `renewal_date` create agenda events.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: Example Magazine
|
||||
start_date: 2026-01-01
|
||||
renewal_date: 2027-01-01
|
||||
price:
|
||||
amount: 99
|
||||
currency: GBP
|
||||
term:
|
||||
duration: 1
|
||||
unit: year
|
||||
email: me@example.com
|
||||
account_url: https://example.com/account
|
||||
account_number: '001234'
|
||||
```
|
||||
|
||||
## `trains.yaml`
|
||||
|
||||
Top-level shape: list of train journeys. Each journey contains one or more legs.
|
||||
|
||||
Used by: agenda transport events, trip loading, trip timeline, maps, stats.
|
||||
|
||||
Required journey fields:
|
||||
|
||||
- `operator`: booking/operator label.
|
||||
- `from`, `to`: station names from `stations.yaml`.
|
||||
- `trip`: trip start date.
|
||||
- `depart`, `arrive`: journey datetimes or dates.
|
||||
- `legs`: list of leg mappings.
|
||||
|
||||
Common optional journey fields:
|
||||
|
||||
- `class`, `number`, `tickets`, `ticket_code`, `total_price`, `co2_kg`.
|
||||
|
||||
Required leg fields:
|
||||
|
||||
- `from`, `to`: station names from `stations.yaml`.
|
||||
- `depart`, `arrive`.
|
||||
- `operator`.
|
||||
|
||||
Common optional leg fields:
|
||||
|
||||
- `train`, `number`, `service`, `service_number`, `service_numbers`, `reporting_number`, `mode`.
|
||||
- Seat/reservation: `coach`, `seat`, `seat_type`, `seat_features`, `reservation_number`, `platform`.
|
||||
- `class`, `trip`, `url`.
|
||||
|
||||
Ticket fields are free-form but commonly include `booking_reference`, `url`, `price`, `currency`, `booking_date`, `ticket`, `ticket_code`, `ticket_type`, `from`, `to`, `class`, `validity`, `route`, `fare`, `quantity`, `seat_reservation`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- operator: eurostar
|
||||
from: London St Pancras
|
||||
to: Brussels Midi
|
||||
trip: 2026-02-06
|
||||
depart: 2026-02-06 15:04:00+00:00
|
||||
arrive: 2026-02-06 18:12:00+01:00
|
||||
class: Standard Premier
|
||||
tickets:
|
||||
- booking_reference: ABCDEF
|
||||
url: https://example.com/booking/ABCDEF
|
||||
price: '89.00'
|
||||
currency: GBP
|
||||
legs:
|
||||
- from: London St Pancras
|
||||
to: Brussels Midi
|
||||
depart: 2026-02-06 15:04:00+00:00
|
||||
arrive: 2026-02-06 18:12:00+01:00
|
||||
coach: 1
|
||||
seat: 41
|
||||
operator: Eurostar
|
||||
```
|
||||
|
||||
## `travel_rewards.yaml`
|
||||
|
||||
Top-level shape: list of travel loyalty accounts.
|
||||
|
||||
Used by: no current in-repo reader was found. The file is structured as account metadata.
|
||||
|
||||
Common fields:
|
||||
|
||||
- `name`: programme name.
|
||||
- `type`: category such as `hotel`, `airline`, `rail`.
|
||||
- `member_number`: membership identifier.
|
||||
- `balance`: current points/miles balance.
|
||||
- `expiry`: expiry date or null.
|
||||
- `url`: account URL.
|
||||
- `person`: account holder key/name.
|
||||
- `email`, `note`.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- name: Example Rewards
|
||||
type: hotel
|
||||
member_number: '123456789'
|
||||
balance: 3665
|
||||
expiry: 2027-08-15
|
||||
url: https://example.com/rewards
|
||||
person: edward
|
||||
```
|
||||
|
||||
## `trips.yaml`
|
||||
|
||||
Top-level shape: list of trip metadata.
|
||||
|
||||
Used by: trip grouping and trip titles.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `trip`: trip start date. This is the grouping key used by travel, accommodation, conferences, and trip events.
|
||||
|
||||
Optional fields:
|
||||
|
||||
- `name`: explicit trip title.
|
||||
- `private`: boolean. Private trips are hidden from unauthenticated users.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- trip: 2026-02-06
|
||||
name: Brussels for FOSDEM
|
||||
private: false
|
||||
```
|
||||
|
|
@ -138,22 +138,21 @@ tr.conf-hl > td {
|
|||
</tr>
|
||||
{% set ns = namespace(prev_month="") %}
|
||||
{% for item in item_list %}
|
||||
{% set month_label = item.start_date.strftime("%B %Y") %}
|
||||
{% set month_label = item.sort_date.strftime("%B %Y") %}
|
||||
{% if month_label != ns.prev_month %}
|
||||
{% set ns.prev_month = month_label %}
|
||||
<tr class="conf-month-row">
|
||||
<td colspan="6">{{ month_label }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.start_date.isoformat() }}|{{ item.name }}">
|
||||
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.sort_date.isoformat() }}|{{ item.name }}">
|
||||
<td class="text-nowrap text-muted small">
|
||||
{%- if item.start_date == item.end_date -%}
|
||||
{{ item.start_date.strftime("%a %-d %b %Y") }}
|
||||
{%- elif item.start_date.year == item.end_date.year and item.start_date.month == item.end_date.month -%}
|
||||
{{ item.start_date.strftime("%a %-d") }}–{{ item.end_date.strftime("%-d %b %Y") }}
|
||||
{%- else -%}
|
||||
{{ item.start_date.strftime("%a %-d %b") }}–{{ item.end_date.strftime("%-d %b %Y") }}
|
||||
{%- endif -%}
|
||||
{{ 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>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,33 @@ def test_insert_sorted_allows_same_url_different_year_without_year_component() -
|
|||
assert updated[1]["name"] == "NewConf"
|
||||
|
||||
|
||||
def test_insert_sorted_supports_nested_dates() -> None:
|
||||
"""Nested dates should be used for sorting."""
|
||||
conferences: list[dict[str, typing.Any]] = [
|
||||
{
|
||||
"name": "PyCascades",
|
||||
"dates": {
|
||||
"status": "approximate",
|
||||
"label": "March 2027",
|
||||
"earliest": date(2027, 3, 1),
|
||||
"latest": date(2027, 3, 31),
|
||||
},
|
||||
}
|
||||
]
|
||||
new_conf: dict[str, typing.Any] = {
|
||||
"name": "FOSDEM",
|
||||
"dates": {
|
||||
"status": "tentative",
|
||||
"start": date(2027, 1, 30),
|
||||
"end": date(2027, 1, 31),
|
||||
},
|
||||
}
|
||||
|
||||
updated = add_new_conference.insert_sorted(conferences, new_conf)
|
||||
|
||||
assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"]
|
||||
|
||||
|
||||
def test_validate_country_normalises_name() -> None:
|
||||
"""Country names should be normalised to alpha-2 codes."""
|
||||
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
||||
|
|
@ -68,6 +95,21 @@ def test_normalise_end_field_defaults_single_day_date() -> None:
|
|||
assert conf["end"] == date(2026, 4, 10)
|
||||
|
||||
|
||||
def test_normalise_end_field_defaults_nested_exact_date() -> None:
|
||||
"""Nested exact dates should get a default end date."""
|
||||
conf: dict[str, typing.Any] = {
|
||||
"name": "PyCon",
|
||||
"dates": {
|
||||
"status": "exact",
|
||||
"start": date(2026, 4, 10),
|
||||
},
|
||||
}
|
||||
|
||||
add_new_conference.normalise_end_field(conf, "plain text")
|
||||
|
||||
assert conf["dates"]["end"] == date(2026, 4, 10)
|
||||
|
||||
|
||||
def test_normalise_end_field_sets_geomob_end_time() -> None:
|
||||
"""Geomob conferences should default to a 22:00 end time."""
|
||||
conf: dict[str, typing.Any] = {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Any
|
|||
import pytest
|
||||
import yaml
|
||||
|
||||
from agenda.conference import Conference, get_list
|
||||
from agenda.conference import Conference, conference_date_fields, get_list
|
||||
from agenda.event import Event
|
||||
|
||||
|
||||
|
|
@ -298,6 +298,104 @@ class TestGetList:
|
|||
assert event.date == datetime(2024, 5, 15, 9, 0)
|
||||
assert event.end_date == datetime(2024, 5, 17, 17, 0)
|
||||
|
||||
def test_get_list_nested_exact_dates(self) -> None:
|
||||
"""Test reading conference with nested exact dates."""
|
||||
yaml_data = [
|
||||
{
|
||||
"name": "PyCon",
|
||||
"topic": "Python",
|
||||
"location": "Portland",
|
||||
"dates": {
|
||||
"status": "exact",
|
||||
"start": date(2024, 5, 15),
|
||||
"end": date(2024, 5, 17),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(yaml_data, f)
|
||||
f.flush()
|
||||
|
||||
events = get_list(f.name)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].date == date(2024, 5, 15)
|
||||
assert events[0].end_date == date(2024, 5, 17)
|
||||
|
||||
def test_get_list_tentative_dates_do_not_create_conference_event(self) -> None:
|
||||
"""Test tentative conference dates are not emitted as calendar events."""
|
||||
yaml_data = [
|
||||
{
|
||||
"name": "FOSDEM",
|
||||
"topic": "FOSDEM",
|
||||
"location": "Brussels",
|
||||
"dates": {
|
||||
"status": "tentative",
|
||||
"start": date(2027, 1, 30),
|
||||
"end": date(2027, 1, 31),
|
||||
"label": "likely first weekend of February 2027",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(yaml_data, f)
|
||||
f.flush()
|
||||
|
||||
events = get_list(f.name)
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_get_list_approximate_dates_keep_cfp_event(self) -> None:
|
||||
"""Test approximate dates do not block CFP reminders."""
|
||||
yaml_data = [
|
||||
{
|
||||
"name": "PyCascades",
|
||||
"topic": "Python",
|
||||
"location": "TBC",
|
||||
"dates": {
|
||||
"status": "approximate",
|
||||
"label": "March 2027",
|
||||
"earliest": date(2027, 3, 1),
|
||||
"latest": date(2027, 3, 31),
|
||||
},
|
||||
"cfp_end": date(2026, 11, 1),
|
||||
}
|
||||
]
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
yaml.dump(yaml_data, f)
|
||||
f.flush()
|
||||
|
||||
events = get_list(f.name)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].name == "cfp_end"
|
||||
assert events[0].date == date(2026, 11, 1)
|
||||
|
||||
def test_conference_date_fields_approximate(self) -> None:
|
||||
"""Test derived fields for approximate conference dates."""
|
||||
fields = conference_date_fields(
|
||||
{
|
||||
"name": "PyCascades",
|
||||
"topic": "Python",
|
||||
"location": "TBC",
|
||||
"dates": {
|
||||
"status": "approximate",
|
||||
"label": "March 2027",
|
||||
"earliest": date(2027, 3, 1),
|
||||
"latest": date(2027, 3, 31),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert fields["date_status"] == "approximate"
|
||||
assert fields["sort_date"] == date(2027, 3, 1)
|
||||
assert fields["latest_date"] == date(2027, 3, 31)
|
||||
assert fields["display_date"] == "March 2027"
|
||||
assert fields["has_exact_dates"] is False
|
||||
|
||||
def test_get_list_invalid_date_order(self) -> None:
|
||||
"""Test that conferences with end before start raise assertion error."""
|
||||
yaml_data = [
|
||||
|
|
|
|||
55
tests/test_conference_list.py
Normal file
55
tests/test_conference_list.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Tests for conference list date handling."""
|
||||
|
||||
from datetime import date
|
||||
import typing
|
||||
|
||||
import yaml
|
||||
|
||||
import agenda.trip
|
||||
import web_view
|
||||
|
||||
|
||||
def test_build_conference_list_supports_inexact_dates(
|
||||
tmp_path: typing.Any, monkeypatch: typing.Any
|
||||
) -> None:
|
||||
"""Conference list should include tentative and approximate dates."""
|
||||
conferences = [
|
||||
{
|
||||
"name": "PyCascades 2027",
|
||||
"topic": "Python",
|
||||
"location": "TBC",
|
||||
"dates": {
|
||||
"status": "approximate",
|
||||
"label": "March 2027",
|
||||
"earliest": date(2027, 3, 1),
|
||||
"latest": date(2027, 3, 31),
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "FOSDEM 2027",
|
||||
"topic": "FOSDEM",
|
||||
"location": "Brussels",
|
||||
"dates": {
|
||||
"status": "tentative",
|
||||
"start": date(2027, 1, 30),
|
||||
"end": date(2027, 1, 31),
|
||||
"label": "likely first weekend of February 2027",
|
||||
},
|
||||
},
|
||||
]
|
||||
(tmp_path / "conferences.yaml").write_text(
|
||||
yaml.safe_dump(conferences), encoding="utf-8"
|
||||
)
|
||||
|
||||
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
|
||||
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
|
||||
|
||||
items = web_view.build_conference_list()
|
||||
|
||||
assert [item["name"] for item in items] == ["FOSDEM 2027", "PyCascades 2027"]
|
||||
assert items[0]["date_status"] == "tentative"
|
||||
assert items[0]["display_date"] == "likely first weekend of February 2027"
|
||||
assert items[0]["sort_date"] == date(2027, 1, 30)
|
||||
assert items[1]["date_status"] == "approximate"
|
||||
assert items[1]["display_date"] == "March 2027"
|
||||
assert items[1]["latest_date"] == date(2027, 3, 31)
|
||||
|
|
@ -17,6 +17,7 @@ import agenda.data
|
|||
import agenda.travel
|
||||
import agenda.trip
|
||||
import agenda.types
|
||||
import agenda.utils
|
||||
|
||||
config = __import__("config.default", fromlist=[""])
|
||||
data_dir = config.PERSONAL_DATA
|
||||
|
|
@ -36,6 +37,23 @@ def check_currency(item: agenda.types.StrDict) -> None:
|
|||
sys.exit(-1)
|
||||
|
||||
|
||||
def check_country_code(
|
||||
item: agenda.types.StrDict, source: str, required: bool = True
|
||||
) -> None:
|
||||
"""Throw error if country code is missing or invalid."""
|
||||
country = item.get("country")
|
||||
if country is None:
|
||||
if not required:
|
||||
return
|
||||
pprint(item)
|
||||
print(f"{source} missing country")
|
||||
sys.exit(-1)
|
||||
if not isinstance(country, str) or not agenda.get_country(country):
|
||||
pprint(item)
|
||||
print(f"{source} has invalid country {country!r}")
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
def get_coords(item: agenda.types.StrDict) -> LatLon | None:
|
||||
"""Return latitude/longitude tuple when present."""
|
||||
if "latitude" in item and "longitude" in item:
|
||||
|
|
@ -250,6 +268,16 @@ def check_trains() -> None:
|
|||
print(len(trains), "trains")
|
||||
|
||||
|
||||
def check_conference_dates(conf_data: agenda.types.StrDict) -> None:
|
||||
"""Check conference start/end range."""
|
||||
try:
|
||||
agenda.conference.validate_conference_date_fields(conf_data)
|
||||
except ValueError as exc:
|
||||
pprint(conf_data)
|
||||
print(f"conference {conf_data.get('name', '[unknown]')!r}: {exc}")
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
def check_conferences() -> None:
|
||||
"""Check conferences and ensure they are in chronological order."""
|
||||
filepath = os.path.join(data_dir, "conferences.yaml")
|
||||
|
|
@ -267,7 +295,11 @@ def check_conferences() -> None:
|
|||
print(f"currency {conf.currency!r} not in {currencies!r}")
|
||||
sys.exit(-1)
|
||||
|
||||
current_start = normalize_datetime(conf_data["start"])
|
||||
check_country_code(conf_data, "conference", required=False)
|
||||
check_conference_dates(conf_data)
|
||||
|
||||
date_fields = agenda.conference.conference_date_fields(conf_data)
|
||||
current_start = normalize_datetime(date_fields["sort_date"])
|
||||
if prev_start and current_start < prev_start:
|
||||
assert prev_conf_data is not None
|
||||
print(f"Out of order conference found:")
|
||||
|
|
@ -290,6 +322,11 @@ def check_events() -> None:
|
|||
last_year = today - timedelta(days=365)
|
||||
next_year = today + timedelta(days=2 * 365)
|
||||
|
||||
filepath = os.path.join(data_dir, "events.yaml")
|
||||
events_data = yaml.safe_load(open(filepath, "r"))
|
||||
for event in events_data:
|
||||
check_country_code(event, "event", required=False)
|
||||
|
||||
events = agenda.events_yaml.read(data_dir, last_year, next_year)
|
||||
print(len(events), "events")
|
||||
|
||||
|
|
@ -314,6 +351,7 @@ def check_accommodation() -> None:
|
|||
for stay in accommodation_list:
|
||||
try:
|
||||
assert all(field in stay for field in required_fields)
|
||||
check_country_code(stay, "accommodation")
|
||||
check_coordinates(stay)
|
||||
except AssertionError:
|
||||
pprint(stay)
|
||||
|
|
@ -347,8 +385,7 @@ def check_airports() -> None:
|
|||
)
|
||||
print(len(airports), "airports")
|
||||
for airport in airports.values():
|
||||
assert "country" in airport
|
||||
assert agenda.get_country(airport["country"])
|
||||
check_country_code(airport, "airport")
|
||||
|
||||
|
||||
def check_stations() -> None:
|
||||
|
|
@ -356,8 +393,31 @@ def check_stations() -> None:
|
|||
stations = agenda.travel.parse_yaml("stations", data_dir)
|
||||
print(len(stations), "stations")
|
||||
for station in stations:
|
||||
assert "country" in station
|
||||
assert agenda.get_country(station["country"])
|
||||
check_country_code(station, "station")
|
||||
|
||||
|
||||
def check_ferry_terminals() -> None:
|
||||
"""Check ferry terminals."""
|
||||
terminals = agenda.travel.parse_yaml("ferry_terminals", data_dir)
|
||||
print(len(terminals), "ferry terminals")
|
||||
for terminal in terminals:
|
||||
check_country_code(terminal, "ferry terminal")
|
||||
|
||||
|
||||
def check_bus_stops() -> None:
|
||||
"""Check bus stops."""
|
||||
stops = agenda.travel.parse_yaml("bus_stops", data_dir)
|
||||
print(len(stops), "bus stops")
|
||||
for stop in stops:
|
||||
check_country_code(stop, "bus stop")
|
||||
|
||||
|
||||
def check_coach_stations() -> None:
|
||||
"""Check coach stations."""
|
||||
stations = agenda.travel.parse_yaml("coach_stations", data_dir)
|
||||
print(len(stations), "coach stations")
|
||||
for station in stations:
|
||||
check_country_code(station, "coach station")
|
||||
|
||||
|
||||
def check_ferries() -> None:
|
||||
|
|
@ -415,8 +475,8 @@ def check_buses() -> None:
|
|||
|
||||
def check_airlines() -> list[agenda.types.StrDict]:
|
||||
"""Check airlines."""
|
||||
airlines = typing.cast(
|
||||
list[agenda.types.StrDict], agenda.travel.parse_yaml("airlines", data_dir)
|
||||
airlines: list[agenda.types.StrDict] = agenda.travel.parse_yaml(
|
||||
"airlines", data_dir
|
||||
)
|
||||
print(len(airlines), "airlines")
|
||||
for airline in airlines:
|
||||
|
|
@ -450,6 +510,9 @@ def check() -> None:
|
|||
check_accommodation()
|
||||
check_airports()
|
||||
check_stations()
|
||||
check_ferry_terminals()
|
||||
check_bus_stops()
|
||||
check_coach_stations()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
30
web_view.py
30
web_view.py
|
|
@ -7,7 +7,6 @@ import hashlib
|
|||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import operator
|
||||
import os.path
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -26,6 +25,7 @@ from authlib.integrations.flask_client import OAuth
|
|||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
import agenda.data
|
||||
import agenda.conference
|
||||
import agenda.error_mail
|
||||
import agenda.fx
|
||||
import agenda.holidays
|
||||
|
|
@ -380,18 +380,18 @@ def build_conference_list() -> list[StrDict]:
|
|||
conference_trip_lookup[key] = trip
|
||||
|
||||
for conf in items:
|
||||
conf["start_date"] = agenda.utils.as_date(conf["start"])
|
||||
conf["end_date"] = agenda.utils.as_date(conf["end"])
|
||||
conf.update(agenda.conference.validate_conference_date_fields(conf))
|
||||
|
||||
price = conf.get("price")
|
||||
if price:
|
||||
conf["price"] = decimal.Decimal(price)
|
||||
|
||||
if "start" in conf:
|
||||
key = (conf["start"], conf["name"])
|
||||
if this_trip := conference_trip_lookup.get(key):
|
||||
conf["linked_trip"] = this_trip
|
||||
|
||||
items.sort(key=operator.itemgetter("start_date"))
|
||||
items.sort(key=lambda item: item["sort_date"])
|
||||
return items
|
||||
|
||||
|
||||
|
|
@ -442,7 +442,7 @@ def _conference_description(conf: StrDict) -> str:
|
|||
|
||||
def build_conference_timeline(
|
||||
current: list[StrDict], future: list[StrDict], today: date, days: int = 90
|
||||
) -> dict | None:
|
||||
) -> dict[str, typing.Any] | None:
|
||||
"""Build data for a Gantt-style timeline of upcoming conferences."""
|
||||
timeline_start = today
|
||||
timeline_end = today + timedelta(days=days)
|
||||
|
|
@ -450,7 +450,9 @@ def build_conference_timeline(
|
|||
visible = [
|
||||
c
|
||||
for c in (current + future)
|
||||
if c["start_date"] <= timeline_end and c["end_date"] >= today
|
||||
if c["has_exact_dates"]
|
||||
and c["start_date"] <= timeline_end
|
||||
and c["end_date"] >= today
|
||||
]
|
||||
if not visible:
|
||||
return None
|
||||
|
|
@ -500,7 +502,9 @@ def build_conference_timeline(
|
|||
d = today.replace(day=1)
|
||||
while d <= timeline_end:
|
||||
off = max((d - timeline_start).days, 0)
|
||||
months.append({"label": d.strftime("%b %Y"), "left_pct": round(off / days * 100, 2)})
|
||||
months.append(
|
||||
{"label": d.strftime("%b %Y"), "left_pct": round(off / days * 100, 2)}
|
||||
)
|
||||
# advance to next month
|
||||
d = (d.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||
|
||||
|
|
@ -525,6 +529,8 @@ def build_conference_ical(items: list[StrDict]) -> bytes:
|
|||
generated = datetime.now(tz=timezone.utc)
|
||||
|
||||
for conf in items:
|
||||
if not conf["has_exact_dates"]:
|
||||
continue
|
||||
start_date = agenda.utils.as_date(conf["start"])
|
||||
end_date = agenda.utils.as_date(conf["end"])
|
||||
end_exclusive = end_date + timedelta(days=1)
|
||||
|
|
@ -556,9 +562,13 @@ def conference_list() -> str:
|
|||
current = [
|
||||
conf
|
||||
for conf in items
|
||||
if conf["start_date"] <= today and conf["end_date"] >= today
|
||||
if conf["has_exact_dates"]
|
||||
and conf["start_date"] <= today
|
||||
and conf["end_date"] >= today
|
||||
]
|
||||
future = [
|
||||
conf for conf in items if conf not in current and conf["latest_date"] >= today
|
||||
]
|
||||
future = [conf for conf in items if conf["start_date"] > today]
|
||||
|
||||
timeline = build_conference_timeline(current, future, today)
|
||||
|
||||
|
|
@ -579,7 +589,7 @@ def past_conference_list() -> str:
|
|||
today = date.today()
|
||||
return flask.render_template(
|
||||
"conference_list.html",
|
||||
past=[conf for conf in build_conference_list() if conf["end_date"] < today],
|
||||
past=[conf for conf in build_conference_list() if conf["latest_date"] < today],
|
||||
today=today,
|
||||
get_country=agenda.get_country,
|
||||
fx_rate=agenda.fx.get_rates(app.config),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue