Add reusable people list import and export

This commit is contained in:
2026-08-24 14:26:30 +02:00
parent 13d06b8a60
commit 2a4481f2f9
6 changed files with 297 additions and 2 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Versioned YAML import and export for reusable People lists."""
from __future__ import annotations
import re
from collections.abc import Sequence
import yaml
from mka.application.meeting_service import ParticipantInput
PEOPLE_YAML_VERSION = 1
ATTENDANCE_VALUES = frozenset({"present", "mentioned_only"})
PARTICIPANT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]*$")
class PeopleYamlError(ValueError):
"""Raised when a reusable People-list document is invalid."""
def export_people_yaml(people: Sequence[ParticipantInput]) -> str:
"""Serialize people deterministically without meeting-specific data."""
document = {
"version": PEOPLE_YAML_VERSION,
"people": [
{
"participant_id": person.participant_id,
"display_name": person.display_name,
"role": person.role,
"organization": person.organization,
"attendance_status": person.attendance_status,
}
for person in people
],
}
return yaml.safe_dump(
document,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
def import_people_yaml(content: str | bytes) -> list[ParticipantInput]:
"""Parse and validate a complete replacement People list."""
try:
if isinstance(content, bytes):
content = content.decode("utf-8")
document = yaml.safe_load(content)
except (yaml.YAMLError, UnicodeDecodeError) as exc:
raise PeopleYamlError(f"Malformed People YAML: {exc}") from exc
if not isinstance(document, dict):
raise PeopleYamlError("People YAML must contain a top-level mapping.")
version = document.get("version")
if type(version) is not int or version != PEOPLE_YAML_VERSION:
raise PeopleYamlError(
f"Unsupported People YAML version {version!r}; expected {PEOPLE_YAML_VERSION}."
)
entries = document.get("people")
if not isinstance(entries, list):
raise PeopleYamlError("People YAML must contain a top-level 'people' list.")
people: list[ParticipantInput] = []
seen_ids: set[str] = set()
for index, entry in enumerate(entries, start=1):
if not isinstance(entry, dict):
raise PeopleYamlError(f"Person {index} must be a mapping.")
participant_id = _required_text(entry, "participant_id", index)
if PARTICIPANT_ID_PATTERN.fullmatch(participant_id) is None:
raise PeopleYamlError(f"Person {index} has invalid participant_id {participant_id!r}.")
if participant_id in seen_ids:
raise PeopleYamlError(f"Duplicate participant_id: {participant_id!r}.")
seen_ids.add(participant_id)
display_name = _required_text(entry, "display_name", index)
role = _optional_text(entry, "role", index)
organization = _optional_text(entry, "organization", index)
attendance_status = entry.get("attendance_status")
if attendance_status not in ATTENDANCE_VALUES:
allowed = ", ".join(sorted(ATTENDANCE_VALUES))
raise PeopleYamlError(
f"Person {index} has invalid attendance_status; expected one of: {allowed}."
)
people.append(
ParticipantInput(
participant_id=participant_id,
display_name=display_name,
role=role,
organization=organization,
attendance_status=attendance_status,
)
)
return people
def _required_text(entry: dict[object, object], field: str, index: int) -> str:
value = entry.get(field)
if not isinstance(value, str) or not value.strip():
raise PeopleYamlError(f"Person {index} requires a non-empty {field}.")
return value
def _optional_text(entry: dict[object, object], field: str, index: int) -> str:
value = entry.get(field, "")
if not isinstance(value, str):
raise PeopleYamlError(f"Person {index} field {field} must be text.")
return value
+48 -2
View File
@@ -18,6 +18,11 @@ from mka.application.meeting_service import (
ProcessingOptions,
stable_id,
)
from mka.application.people_yaml import (
PeopleYamlError,
export_people_yaml,
import_people_yaml,
)
from mka.integrations.meeting_lab import (
MeetingLabGateway,
MeetingLabUnavailableError,
@@ -48,9 +53,41 @@ def _initialize_state() -> None:
st.session_state.setdefault("edited_protocol", "")
def _people_to_rows(people: list[ParticipantInput]) -> list[dict[str, str]]:
"""Create fresh widget rows while preserving reusable person IDs."""
return [
{
"row_id": uuid4().hex,
"participant_id": person.participant_id,
"display_name": person.display_name,
"role": person.role,
"organization": person.organization,
"attendance_status": person.attendance_status,
}
for person in people
]
def _render_participants() -> list[ParticipantInput]:
st.subheader("People")
st.caption("Record whether each relevant person attended or was only mentioned.")
imported_file = st.file_uploader(
"Import people",
type=["yaml", "yml"],
help="Replace the current People list from a versioned YAML export.",
)
if st.button("Import people list", disabled=imported_file is None):
try:
imported_people = import_people_yaml(imported_file.getvalue())
except PeopleYamlError as exc:
st.error(str(exc))
else:
st.session_state.participants = _people_to_rows(imported_people)
st.session_state.people_import_message = f"Imported {len(imported_people)} people."
st.rerun()
if message := st.session_state.pop("people_import_message", None):
st.success(message)
rows = st.session_state.participants
remove_index: int | None = None
for index, row in enumerate(rows):
@@ -84,10 +121,11 @@ def _render_participants() -> list[ParticipantInput]:
if remove_index is not None:
rows.pop(remove_index)
st.rerun()
if st.button("Add person"):
action_columns = st.columns(2)
if action_columns[0].button("Add person"):
rows.append(_new_participant())
st.rerun()
return [
people = [
ParticipantInput(
participant_id=row["participant_id"],
display_name=row["display_name"],
@@ -97,6 +135,14 @@ def _render_participants() -> list[ParticipantInput]:
)
for row in rows
]
exported_yaml = export_people_yaml(people).encode("utf-8")
action_columns[1].download_button(
"Export people",
data=exported_yaml,
file_name="people.yaml",
mime="application/yaml",
)
return people
def _progress_callback(