T05: Calendar plugin backend — appointments + tasks + kanban + ICS + resources + recurrence — 69 tests, 86.87% coverage
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""ICS export/import utilities — manual parser (no external icalendar dependency)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_lib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.builtins.calendar.models import CalendarEntry
|
||||
|
||||
|
||||
def _fmt_ics_datetime(dt: datetime) -> str:
|
||||
"""Format datetime as ICS UTC string: 20260101T120000Z."""
|
||||
return dt.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def _fmt_ics_date(d: Any) -> str:
|
||||
"""Format date as ICS date string: 20260101."""
|
||||
if isinstance(d, datetime):
|
||||
return d.strftime("%Y%m%d")
|
||||
return str(d).replace("-", "")
|
||||
|
||||
|
||||
def export_entries_to_ics(entries: list[CalendarEntry]) -> str:
|
||||
"""Export calendar entries to ICS format string."""
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//LeoCRM//Calendar//EN",
|
||||
]
|
||||
for entry in entries:
|
||||
lines.append("BEGIN:VEVENT")
|
||||
lines.append(f"UID:{entry.id}@leocrm")
|
||||
if entry.start_at:
|
||||
lines.append(f"DTSTART:{_fmt_ics_datetime(entry.start_at)}")
|
||||
if entry.end_at:
|
||||
lines.append(f"DTEND:{_fmt_ics_datetime(entry.end_at)}")
|
||||
lines.append(f"SUMMARY:{_escape_ics(entry.title)}")
|
||||
if entry.description:
|
||||
lines.append(f"DESCRIPTION:{_escape_ics(entry.description)}")
|
||||
if entry.location:
|
||||
lines.append(f"LOCATION:{_escape_ics(entry.location)}")
|
||||
lines.append("END:VEVENT")
|
||||
lines.append("END:VCALENDAR")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
def _escape_ics(text: str) -> str:
|
||||
"""Escape special characters for ICS format."""
|
||||
return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
|
||||
|
||||
|
||||
def parse_ics(content: str) -> list[dict[str, Any]]:
|
||||
"""Parse ICS file content into a list of event dicts.
|
||||
|
||||
Returns list of {uid, dtstart, dtend, summary, description, location}.
|
||||
"""
|
||||
events: list[dict[str, Any]] = []
|
||||
current_event: dict[str, Any] | None = None
|
||||
current_key: str | None = None
|
||||
current_val: str = ""
|
||||
|
||||
for raw_line in content.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Handle line folding (starts with space)
|
||||
if line.startswith(" ") and current_key:
|
||||
current_val += line[1:]
|
||||
continue
|
||||
# Save previous key if we were accumulating
|
||||
if current_key and current_event is not None:
|
||||
current_event[current_key] = current_val
|
||||
current_key = None
|
||||
current_val = ""
|
||||
|
||||
if line == "BEGIN:VEVENT":
|
||||
current_event = {}
|
||||
elif line == "END:VEVENT":
|
||||
if current_key and current_event is not None:
|
||||
current_event[current_key] = current_val
|
||||
current_key = None
|
||||
current_val = ""
|
||||
if current_event is not None:
|
||||
events.append(current_event)
|
||||
current_event = None
|
||||
elif current_event is not None and ":" in line:
|
||||
# Parse property name:value (may have params like DTSTART;TZID=...)
|
||||
prop_part, value = line.split(":", 1)
|
||||
prop_name = prop_part.split(";")[0].upper()
|
||||
current_key = _map_ics_property(prop_name)
|
||||
current_val = value
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _map_ics_property(name: str) -> str:
|
||||
"""Map ICS property names to internal field names."""
|
||||
mapping = {
|
||||
"UID": "uid",
|
||||
"DTSTART": "dtstart",
|
||||
"DTEND": "dtend",
|
||||
"SUMMARY": "summary",
|
||||
"DESCRIPTION": "description",
|
||||
"LOCATION": "location",
|
||||
}
|
||||
return mapping.get(name, name.lower())
|
||||
|
||||
|
||||
def _parse_ics_datetime(val: str) -> datetime | None:
|
||||
"""Parse ICS datetime string (various formats)."""
|
||||
val = val.strip()
|
||||
# Try UTC format: 20260101T120000Z
|
||||
try:
|
||||
return datetime.strptime(val, "%Y%m%dT%H%M%SZ")
|
||||
except ValueError:
|
||||
pass
|
||||
# Try local format: 20260101T120000
|
||||
try:
|
||||
return datetime.strptime(val, "%Y%m%dT%H%M%S")
|
||||
except ValueError:
|
||||
pass
|
||||
# Try date only: 20260101
|
||||
try:
|
||||
return datetime.strptime(val, "%Y%m%d")
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def ics_events_to_entry_data(
|
||||
events: list[dict[str, Any]], calendar_id: Any, tenant_id: Any, user_id: Any
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert parsed ICS events into CalendarEntry-compatible dicts."""
|
||||
entries = []
|
||||
for event in events:
|
||||
title = event.get("summary", "Imported Event")
|
||||
start_at = _parse_ics_datetime(event.get("dtstart", ""))
|
||||
end_at = _parse_ics_datetime(event.get("dtend", ""))
|
||||
entry_data = {
|
||||
"id": uuid_lib.uuid4(),
|
||||
"tenant_id": tenant_id,
|
||||
"calendar_id": calendar_id,
|
||||
"entry_type": "appointment",
|
||||
"subtype": "normal",
|
||||
"title": title,
|
||||
"description": event.get("description"),
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"all_day": False,
|
||||
"location": event.get("location"),
|
||||
"due_date": None,
|
||||
"priority": "medium",
|
||||
"status": "open",
|
||||
"assigned_to": None,
|
||||
"reminder": None,
|
||||
"recurrence": None,
|
||||
"source_mail_id": None,
|
||||
"created_by": user_id,
|
||||
"deleted_at": None,
|
||||
}
|
||||
entries.append(entry_data)
|
||||
return entries
|
||||
Reference in New Issue
Block a user