parent
14f5baf77c
commit
098c7e4447
9 changed files with 464 additions and 66 deletions
|
|
@ -16,6 +16,8 @@ import pycountry
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from agenda.conference import conference_date_fields
|
||||||
|
|
||||||
USER_AGENT = "add-new-conference/0.1"
|
USER_AGENT = "add-new-conference/0.1"
|
||||||
COORDINATE_PATTERNS = (
|
COORDINATE_PATTERNS = (
|
||||||
re.compile(r"@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)"),
|
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 = html2text.HTML2Text()
|
||||||
text_maker.ignore_links = True
|
text_maker.ignore_links = True
|
||||||
text_maker.ignore_images = 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:
|
def parse_osm_url(url: str) -> tuple[float, float] | None:
|
||||||
|
|
@ -267,13 +271,13 @@ def insert_sorted(
|
||||||
) -> list[dict[str, typing.Any]]:
|
) -> list[dict[str, typing.Any]]:
|
||||||
"""Insert a conference sorted by start date and skip duplicate URLs."""
|
"""Insert a conference sorted by start date and skip duplicate URLs."""
|
||||||
new_url = new_conf.get("url")
|
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
|
new_year = new_start.year
|
||||||
|
|
||||||
if new_url:
|
if new_url:
|
||||||
for conf in conferences:
|
for conf in conferences:
|
||||||
if conf.get("url") == new_url:
|
if conf.get("url") == new_url:
|
||||||
existing_start = parse_date(str(conf["start"]))
|
existing_start = conference_sort_datetime(conf)
|
||||||
existing_year = existing_start.year
|
existing_year = existing_start.year
|
||||||
|
|
||||||
if url_has_year_component(new_url):
|
if url_has_year_component(new_url):
|
||||||
|
|
@ -287,7 +291,7 @@ def insert_sorted(
|
||||||
return conferences
|
return conferences
|
||||||
|
|
||||||
for idx, conf in enumerate(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:
|
if new_start < existing_start:
|
||||||
conferences.insert(idx, new_conf)
|
conferences.insert(idx, new_conf)
|
||||||
return conferences
|
return conferences
|
||||||
|
|
@ -295,6 +299,14 @@ def insert_sorted(
|
||||||
return conferences
|
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:
|
def validate_country(conf: dict[str, typing.Any]) -> None:
|
||||||
"""Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible."""
|
"""Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible."""
|
||||||
country = conf.get("country")
|
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:
|
def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> None:
|
||||||
"""Ensure an end value exists, with a Geomob-specific fallback."""
|
"""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:
|
if start_value is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -395,9 +411,16 @@ def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> No
|
||||||
end_hour = 22
|
end_hour = 22
|
||||||
|
|
||||||
geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0)
|
geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0)
|
||||||
new_conf["end"] = same_type_as_start(
|
end_value = same_type_as_start(start_value, geomob_end, prefer_datetime=True)
|
||||||
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
|
return
|
||||||
|
|
||||||
if "end" not in new_conf:
|
if "end" not in new_conf:
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,18 @@
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import decimal
|
import decimal
|
||||||
|
import typing
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
from .event import Event
|
from .event import Event
|
||||||
|
from .types import DateOrDateTime, StrDict
|
||||||
|
|
||||||
MAX_CONF_DAYS = 20
|
MAX_CONF_DAYS = 20
|
||||||
|
DATE_STATUSES = {"exact", "approximate", "tentative"}
|
||||||
|
DATED_STATUSES = {"exact", "tentative"}
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
|
|
@ -19,8 +23,8 @@ class Conference:
|
||||||
name: str
|
name: str
|
||||||
topic: str
|
topic: str
|
||||||
location: str
|
location: str
|
||||||
start: date | datetime
|
start: date | datetime | None = None
|
||||||
end: date | datetime
|
end: date | datetime | None = None
|
||||||
trip: date | None = None
|
trip: date | None = None
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
venue: str | None = None
|
venue: str | None = None
|
||||||
|
|
@ -46,6 +50,7 @@ class Conference:
|
||||||
attendees: int | None = None
|
attendees: int | None = None
|
||||||
hashtag: str | None = None
|
hashtag: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
dates: StrDict | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
|
|
@ -57,22 +62,135 @@ 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]:
|
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] = []
|
||||||
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))):
|
for item in yaml.safe_load(open(filepath, "r")):
|
||||||
assert conf.start <= conf.end
|
try:
|
||||||
duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days
|
fields = validate_conference_date_fields(item)
|
||||||
assert duration < MAX_CONF_DAYS
|
except ValueError as exc:
|
||||||
event = Event(
|
raise AssertionError(str(exc)) from exc
|
||||||
name="conference",
|
normalized_item = dict(item)
|
||||||
date=conf.start,
|
if "start" in fields:
|
||||||
end_date=conf.end,
|
normalized_item["start"] = fields["start"]
|
||||||
title=conf.display_name,
|
normalized_item["end"] = fields["end"]
|
||||||
url=conf.url,
|
conf = Conference(**normalized_item)
|
||||||
going=conf.going,
|
|
||||||
)
|
if fields["has_exact_dates"]:
|
||||||
events.append(event)
|
assert conf.start is not None and conf.end is not None
|
||||||
|
event = Event(
|
||||||
|
name="conference",
|
||||||
|
date=conf.start,
|
||||||
|
end_date=conf.end,
|
||||||
|
title=conf.display_name,
|
||||||
|
url=conf.url,
|
||||||
|
going=conf.going,
|
||||||
|
)
|
||||||
|
events.append(event)
|
||||||
if not conf.cfp_end:
|
if not conf.cfp_end:
|
||||||
continue
|
continue
|
||||||
cfp_end_event = Event(
|
cfp_end_event = Event(
|
||||||
|
|
|
||||||
|
|
@ -261,8 +261,25 @@ Required fields:
|
||||||
- `name`: event name.
|
- `name`: event name.
|
||||||
- `topic`: topic/category.
|
- `topic`: topic/category.
|
||||||
- `location`: city or location label.
|
- `location`: city or location label.
|
||||||
- `start`: date or datetime.
|
- Date information, either as legacy top-level `start` and `end`, or preferred nested `dates`.
|
||||||
- `end`: date or datetime. Must be no earlier than `start`, and duration must be under 20 days.
|
|
||||||
|
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:
|
Common optional fields:
|
||||||
|
|
||||||
|
|
@ -273,7 +290,7 @@ Common optional fields:
|
||||||
- Money/tickets: `free`, `price`, `currency`, `ticket_type`.
|
- Money/tickets: `free`, `price`, `currency`, `ticket_type`.
|
||||||
- Other flags: `hackathon`, `attendees`.
|
- Other flags: `hackathon`, `attendees`.
|
||||||
|
|
||||||
Example:
|
Exact example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: FOSDEM
|
- name: FOSDEM
|
||||||
|
|
@ -281,8 +298,10 @@ Example:
|
||||||
location: Brussels
|
location: Brussels
|
||||||
country: be
|
country: be
|
||||||
trip: 2026-02-06
|
trip: 2026-02-06
|
||||||
start: 2026-02-07
|
dates:
|
||||||
end: 2026-02-08
|
status: exact
|
||||||
|
start: 2026-02-07
|
||||||
|
end: 2026-02-08
|
||||||
attend_start: 2026-02-07 14:00:00+01:00
|
attend_start: 2026-02-07 14:00:00+01:00
|
||||||
attend_end: 2026-02-08
|
attend_end: 2026-02-08
|
||||||
going: true
|
going: true
|
||||||
|
|
@ -296,6 +315,46 @@ Example:
|
||||||
longitude: 4.3822
|
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`
|
## `entities.yaml`
|
||||||
|
|
||||||
Top-level shape: list of people/entities.
|
Top-level shape: list of people/entities.
|
||||||
|
|
|
||||||
|
|
@ -138,22 +138,21 @@ tr.conf-hl > td {
|
||||||
</tr>
|
</tr>
|
||||||
{% set ns = namespace(prev_month="") %}
|
{% set ns = namespace(prev_month="") %}
|
||||||
{% for item in item_list %}
|
{% 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 %}
|
{% 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="6">{{ month_label }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% 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">
|
<td class="text-nowrap text-muted small">
|
||||||
{%- if item.start_date == item.end_date -%}
|
{{ item.display_date }}
|
||||||
{{ item.start_date.strftime("%a %-d %b %Y") }}
|
{% if item.date_status == "tentative" %}
|
||||||
{%- elif item.start_date.year == item.end_date.year and item.start_date.month == item.end_date.month -%}
|
<span class="badge text-bg-warning ms-1">tentative</span>
|
||||||
{{ item.start_date.strftime("%a %-d") }}–{{ item.end_date.strftime("%-d %b %Y") }}
|
{% elif item.date_status == "approximate" %}
|
||||||
{%- else -%}
|
<span class="badge text-bg-secondary ms-1">approximate</span>
|
||||||
{{ item.start_date.strftime("%a %-d %b") }}–{{ item.end_date.strftime("%-d %b %Y") }}
|
{% endif %}
|
||||||
{%- endif -%}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if item.url %}<a href="{{ item.url }}">{{ item.name }}</a>
|
{% 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"
|
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:
|
def test_validate_country_normalises_name() -> None:
|
||||||
"""Country names should be normalised to alpha-2 codes."""
|
"""Country names should be normalised to alpha-2 codes."""
|
||||||
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
||||||
|
|
@ -68,6 +95,21 @@ def test_normalise_end_field_defaults_single_day_date() -> None:
|
||||||
assert conf["end"] == date(2026, 4, 10)
|
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:
|
def test_normalise_end_field_sets_geomob_end_time() -> None:
|
||||||
"""Geomob conferences should default to a 22:00 end time."""
|
"""Geomob conferences should default to a 22:00 end time."""
|
||||||
conf: dict[str, typing.Any] = {
|
conf: dict[str, typing.Any] = {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from agenda.conference import Conference, get_list
|
from agenda.conference import Conference, conference_date_fields, get_list
|
||||||
from agenda.event import Event
|
from agenda.event import Event
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -298,6 +298,104 @@ class TestGetList:
|
||||||
assert event.date == datetime(2024, 5, 15, 9, 0)
|
assert event.date == datetime(2024, 5, 15, 9, 0)
|
||||||
assert event.end_date == datetime(2024, 5, 17, 17, 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:
|
def test_get_list_invalid_date_order(self) -> None:
|
||||||
"""Test that conferences with end before start raise assertion error."""
|
"""Test that conferences with end before start raise assertion error."""
|
||||||
yaml_data = [
|
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)
|
||||||
|
|
@ -268,20 +268,13 @@ def check_trains() -> None:
|
||||||
print(len(trains), "trains")
|
print(len(trains), "trains")
|
||||||
|
|
||||||
|
|
||||||
def check_conference_dates(conf: agenda.conference.Conference) -> None:
|
def check_conference_dates(conf_data: agenda.types.StrDict) -> None:
|
||||||
"""Check conference start/end range."""
|
"""Check conference start/end range."""
|
||||||
if normalize_datetime(conf.start) > normalize_datetime(conf.end):
|
try:
|
||||||
pprint(conf)
|
agenda.conference.validate_conference_date_fields(conf_data)
|
||||||
print(f"conference {conf.name!r} ends before it starts")
|
except ValueError as exc:
|
||||||
sys.exit(-1)
|
pprint(conf_data)
|
||||||
|
print(f"conference {conf_data.get('name', '[unknown]')!r}: {exc}")
|
||||||
duration = (agenda.utils.as_date(conf.end) - agenda.utils.as_date(conf.start)).days
|
|
||||||
if duration >= agenda.conference.MAX_CONF_DAYS:
|
|
||||||
pprint(conf)
|
|
||||||
print(
|
|
||||||
f"conference {conf.name!r} is {duration} days; "
|
|
||||||
+ f"maximum is {agenda.conference.MAX_CONF_DAYS - 1}"
|
|
||||||
)
|
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -303,9 +296,10 @@ def check_conferences() -> None:
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
check_country_code(conf_data, "conference", required=False)
|
check_country_code(conf_data, "conference", required=False)
|
||||||
check_conference_dates(conf)
|
check_conference_dates(conf_data)
|
||||||
|
|
||||||
current_start = normalize_datetime(conf_data["start"])
|
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:
|
if prev_start and current_start < prev_start:
|
||||||
assert prev_conf_data is not None
|
assert prev_conf_data is not None
|
||||||
print(f"Out of order conference found:")
|
print(f"Out of order conference found:")
|
||||||
|
|
|
||||||
36
web_view.py
36
web_view.py
|
|
@ -7,7 +7,6 @@ import hashlib
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import operator
|
|
||||||
import os.path
|
import os.path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -26,6 +25,7 @@ from authlib.integrations.flask_client import OAuth
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
import agenda.data
|
import agenda.data
|
||||||
|
import agenda.conference
|
||||||
import agenda.error_mail
|
import agenda.error_mail
|
||||||
import agenda.fx
|
import agenda.fx
|
||||||
import agenda.holidays
|
import agenda.holidays
|
||||||
|
|
@ -380,18 +380,18 @@ def build_conference_list() -> list[StrDict]:
|
||||||
conference_trip_lookup[key] = trip
|
conference_trip_lookup[key] = trip
|
||||||
|
|
||||||
for conf in items:
|
for conf in items:
|
||||||
conf["start_date"] = agenda.utils.as_date(conf["start"])
|
conf.update(agenda.conference.validate_conference_date_fields(conf))
|
||||||
conf["end_date"] = agenda.utils.as_date(conf["end"])
|
|
||||||
|
|
||||||
price = conf.get("price")
|
price = conf.get("price")
|
||||||
if price:
|
if price:
|
||||||
conf["price"] = decimal.Decimal(price)
|
conf["price"] = decimal.Decimal(price)
|
||||||
|
|
||||||
key = (conf["start"], conf["name"])
|
if "start" in conf:
|
||||||
if this_trip := conference_trip_lookup.get(key):
|
key = (conf["start"], conf["name"])
|
||||||
conf["linked_trip"] = this_trip
|
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
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -442,7 +442,7 @@ def _conference_description(conf: StrDict) -> str:
|
||||||
|
|
||||||
def build_conference_timeline(
|
def build_conference_timeline(
|
||||||
current: list[StrDict], future: list[StrDict], today: date, days: int = 90
|
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."""
|
"""Build data for a Gantt-style timeline of upcoming conferences."""
|
||||||
timeline_start = today
|
timeline_start = today
|
||||||
timeline_end = today + timedelta(days=days)
|
timeline_end = today + timedelta(days=days)
|
||||||
|
|
@ -450,7 +450,9 @@ def build_conference_timeline(
|
||||||
visible = [
|
visible = [
|
||||||
c
|
c
|
||||||
for c in (current + future)
|
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:
|
if not visible:
|
||||||
return None
|
return None
|
||||||
|
|
@ -500,7 +502,9 @@ def build_conference_timeline(
|
||||||
d = today.replace(day=1)
|
d = today.replace(day=1)
|
||||||
while d <= timeline_end:
|
while d <= timeline_end:
|
||||||
off = max((d - timeline_start).days, 0)
|
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
|
# advance to next month
|
||||||
d = (d.replace(day=28) + timedelta(days=4)).replace(day=1)
|
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)
|
generated = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
for conf in items:
|
for conf in items:
|
||||||
|
if not conf["has_exact_dates"]:
|
||||||
|
continue
|
||||||
start_date = agenda.utils.as_date(conf["start"])
|
start_date = agenda.utils.as_date(conf["start"])
|
||||||
end_date = agenda.utils.as_date(conf["end"])
|
end_date = agenda.utils.as_date(conf["end"])
|
||||||
end_exclusive = end_date + timedelta(days=1)
|
end_exclusive = end_date + timedelta(days=1)
|
||||||
|
|
@ -556,9 +562,13 @@ def conference_list() -> str:
|
||||||
current = [
|
current = [
|
||||||
conf
|
conf
|
||||||
for conf in items
|
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)
|
timeline = build_conference_timeline(current, future, today)
|
||||||
|
|
||||||
|
|
@ -579,7 +589,7 @@ def past_conference_list() -> str:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
return flask.render_template(
|
return flask.render_template(
|
||||||
"conference_list.html",
|
"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,
|
today=today,
|
||||||
get_country=agenda.get_country,
|
get_country=agenda.get_country,
|
||||||
fx_rate=agenda.fx.get_rates(app.config),
|
fx_rate=agenda.fx.get_rates(app.config),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue