Initialize GovOPlaN calendar module
This commit is contained in:
5
src/govoplan_calendar/__init__.py
Normal file
5
src/govoplan_calendar/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN calendar module."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.4"
|
||||
1
src/govoplan_calendar/backend/__init__.py
Normal file
1
src/govoplan_calendar/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend package for the GovOPlaN calendar module."""
|
||||
1
src/govoplan_calendar/backend/db/__init__.py
Normal file
1
src/govoplan_calendar/backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Calendar database models."""
|
||||
97
src/govoplan_calendar/backend/db/models.py
Normal file
97
src/govoplan_calendar/backend/db/models.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class CalendarCollection(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_collections"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_calendar_collections_active_slug",
|
||||
"tenant_id",
|
||||
"slug",
|
||||
unique=True,
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
Index("ix_calendar_collections_owner", "tenant_id", "owner_type", "owner_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
|
||||
color: Mapped[str | None] = mapped_column(String(20))
|
||||
owner_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
visibility: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
events: Mapped[list["CalendarEvent"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CalendarEvent(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
||||
Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"),
|
||||
Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"),
|
||||
Index("ix_calendar_events_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
location: Mapped[str | None] = mapped_column(String(500))
|
||||
status: Mapped[str] = mapped_column(String(40), default="CONFIRMED", nullable=False, index=True)
|
||||
transparency: Mapped[str] = mapped_column(String(20), default="OPAQUE", nullable=False)
|
||||
classification: Mapped[str] = mapped_column(String(20), default="PUBLIC", nullable=False)
|
||||
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
timezone: Mapped[str | None] = mapped_column(String(100))
|
||||
organizer: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
attendees: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
categories: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
rrule: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
rdate: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
exdate: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
reminders: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
related_to: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
source_kind: Mapped[str] = mapped_column(String(30), default="local", nullable=False, index=True)
|
||||
source_href: Mapped[str | None] = mapped_column(String(1000))
|
||||
etag: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
raw_ics: Mapped[str | None] = mapped_column(Text)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
|
||||
|
||||
|
||||
__all__ = ["CalendarCollection", "CalendarEvent", "new_uuid"]
|
||||
309
src/govoplan_calendar/backend/ical.py
Normal file
309
src/govoplan_calendar/backend/ical.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timezone
|
||||
from email.utils import format_datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ICalendarError(ValueError):
|
||||
"""Raised when iCalendar content cannot be parsed as a usable VEVENT."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ICalendarProperty:
|
||||
name: str
|
||||
value: str
|
||||
params: dict[str, list[str]]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"name": self.name, "value": self.value, "params": self.params}
|
||||
|
||||
|
||||
def parse_vevent(ics: str) -> dict[str, Any]:
|
||||
lines = unfold_ical_lines(ics)
|
||||
event_lines = component_lines(lines, "VEVENT")
|
||||
if not event_lines:
|
||||
raise ICalendarError("No VEVENT component found")
|
||||
|
||||
properties = [parse_content_line(line) for line in event_lines]
|
||||
props = [prop.as_dict() for prop in properties]
|
||||
by_name = group_properties(properties)
|
||||
|
||||
uid = first_value(by_name, "UID")
|
||||
if not uid:
|
||||
raise ICalendarError("VEVENT is missing UID")
|
||||
dtstart_prop = first_property(by_name, "DTSTART")
|
||||
if dtstart_prop is None:
|
||||
raise ICalendarError("VEVENT is missing DTSTART")
|
||||
|
||||
start_at, all_day, tzid = parse_temporal_property(dtstart_prop)
|
||||
end_at = None
|
||||
duration_seconds = None
|
||||
dtend_prop = first_property(by_name, "DTEND")
|
||||
if dtend_prop is not None:
|
||||
end_at, _end_all_day, _end_tzid = parse_temporal_property(dtend_prop)
|
||||
duration_seconds = int((end_at - start_at).total_seconds())
|
||||
elif first_value(by_name, "DURATION"):
|
||||
# Keep full DURATION semantics in raw properties. The first module
|
||||
# stores it but does not yet calculate every RFC 5545 duration form.
|
||||
duration_seconds = None
|
||||
|
||||
summary = first_value(by_name, "SUMMARY") or "(Untitled event)"
|
||||
organizer = property_party(first_property(by_name, "ORGANIZER"))
|
||||
attendees = [property_party(prop) for prop in by_name.get("ATTENDEE", [])]
|
||||
categories = split_csv(first_value(by_name, "CATEGORIES") or "")
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"recurrence_id": first_value(by_name, "RECURRENCE-ID"),
|
||||
"sequence": int_or_zero(first_value(by_name, "SEQUENCE")),
|
||||
"summary": summary,
|
||||
"description": first_value(by_name, "DESCRIPTION"),
|
||||
"location": first_value(by_name, "LOCATION"),
|
||||
"status": (first_value(by_name, "STATUS") or "CONFIRMED").upper(),
|
||||
"transparency": (first_value(by_name, "TRANSP") or "OPAQUE").upper(),
|
||||
"classification": (first_value(by_name, "CLASS") or "PUBLIC").upper(),
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"duration_seconds": duration_seconds,
|
||||
"all_day": all_day,
|
||||
"timezone": tzid,
|
||||
"organizer": organizer,
|
||||
"attendees": attendees,
|
||||
"categories": categories,
|
||||
"rrule": parse_key_value_property(first_value(by_name, "RRULE")),
|
||||
"rdate": [prop.as_dict() for prop in by_name.get("RDATE", [])],
|
||||
"exdate": [prop.as_dict() for prop in by_name.get("EXDATE", [])],
|
||||
"attachments": [prop.as_dict() for prop in by_name.get("ATTACH", [])],
|
||||
"related_to": [prop.as_dict() for prop in by_name.get("RELATED-TO", [])],
|
||||
"icalendar": {"component": "VEVENT", "properties": props},
|
||||
"raw_ics": normalize_ics(ics),
|
||||
}
|
||||
|
||||
|
||||
def event_to_ics(event: Any) -> str:
|
||||
properties = [
|
||||
("UID", {}, event.uid),
|
||||
("DTSTAMP", {}, format_ical_datetime(datetime.now(timezone.utc))),
|
||||
("DTSTART", date_params(event), format_ical_temporal(event.start_at, all_day=event.all_day)),
|
||||
]
|
||||
if event.end_at is not None:
|
||||
properties.append(("DTEND", date_params(event), format_ical_temporal(event.end_at, all_day=event.all_day)))
|
||||
properties.extend(
|
||||
[
|
||||
("SUMMARY", {}, event.summary),
|
||||
("SEQUENCE", {}, str(event.sequence or 0)),
|
||||
("STATUS", {}, event.status),
|
||||
("TRANSP", {}, event.transparency),
|
||||
("CLASS", {}, event.classification),
|
||||
]
|
||||
)
|
||||
if event.description:
|
||||
properties.append(("DESCRIPTION", {}, event.description))
|
||||
if event.location:
|
||||
properties.append(("LOCATION", {}, event.location))
|
||||
for category in event.categories or []:
|
||||
if category:
|
||||
properties.append(("CATEGORIES", {}, category))
|
||||
if event.rrule:
|
||||
properties.append(("RRULE", {}, serialize_key_value_property(event.rrule)))
|
||||
if event.organizer:
|
||||
properties.append(party_to_property("ORGANIZER", event.organizer))
|
||||
for attendee in event.attendees or []:
|
||||
properties.append(party_to_property("ATTENDEE", attendee))
|
||||
|
||||
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//GovOPlaN//Calendar//EN", "CALSCALE:GREGORIAN", "BEGIN:VEVENT"]
|
||||
lines.extend(fold_ical_line(serialize_content_line(name, params, value)) for name, params, value in properties if value is not None)
|
||||
lines.extend(["END:VEVENT", "END:VCALENDAR", ""])
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
def unfold_ical_lines(ics: str) -> list[str]:
|
||||
raw_lines = ics.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
lines: list[str] = []
|
||||
for line in raw_lines:
|
||||
if not line:
|
||||
continue
|
||||
if line[:1] in {" ", "\t"} and lines:
|
||||
lines[-1] += line[1:]
|
||||
else:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def component_lines(lines: list[str], component: str) -> list[str]:
|
||||
begin = f"BEGIN:{component.upper()}"
|
||||
end = f"END:{component.upper()}"
|
||||
depth = 0
|
||||
collected: list[str] = []
|
||||
for line in lines:
|
||||
upper = line.upper()
|
||||
if upper == begin:
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
collected = []
|
||||
continue
|
||||
if upper == end and depth:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return collected
|
||||
continue
|
||||
if depth:
|
||||
collected.append(line)
|
||||
return []
|
||||
|
||||
|
||||
def parse_content_line(line: str) -> ICalendarProperty:
|
||||
head, sep, value = line.partition(":")
|
||||
if not sep:
|
||||
raise ICalendarError(f"Invalid iCalendar content line: {line}")
|
||||
parts = head.split(";")
|
||||
name = parts[0].upper()
|
||||
params: dict[str, list[str]] = {}
|
||||
for raw_param in parts[1:]:
|
||||
key, param_sep, raw_value = raw_param.partition("=")
|
||||
if not param_sep:
|
||||
params[key.upper()] = [""]
|
||||
else:
|
||||
params[key.upper()] = [unescape_text(item.strip('"')) for item in raw_value.split(",")]
|
||||
return ICalendarProperty(name=name, params=params, value=unescape_text(value))
|
||||
|
||||
|
||||
def group_properties(properties: list[ICalendarProperty]) -> dict[str, list[ICalendarProperty]]:
|
||||
grouped: dict[str, list[ICalendarProperty]] = {}
|
||||
for prop in properties:
|
||||
grouped.setdefault(prop.name, []).append(prop)
|
||||
return grouped
|
||||
|
||||
|
||||
def first_property(grouped: dict[str, list[ICalendarProperty]], name: str) -> ICalendarProperty | None:
|
||||
items = grouped.get(name.upper()) or []
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def first_value(grouped: dict[str, list[ICalendarProperty]], name: str) -> str | None:
|
||||
prop = first_property(grouped, name)
|
||||
return prop.value if prop else None
|
||||
|
||||
|
||||
def parse_temporal_property(prop: ICalendarProperty) -> tuple[datetime, bool, str | None]:
|
||||
value_type = (prop.params.get("VALUE") or [""])[0].upper()
|
||||
tzid = (prop.params.get("TZID") or [None])[0]
|
||||
if value_type == "DATE" or (len(prop.value) == 8 and "T" not in prop.value):
|
||||
parsed_date = datetime.strptime(prop.value, "%Y%m%d").date()
|
||||
return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc), True, tzid
|
||||
return parse_ical_datetime(prop.value), False, tzid
|
||||
|
||||
|
||||
def parse_ical_datetime(value: str) -> datetime:
|
||||
if value.endswith("Z"):
|
||||
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
parsed = datetime.strptime(value, "%Y%m%dT%H%M%S")
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def format_ical_temporal(value: datetime, *, all_day: bool) -> str:
|
||||
if all_day:
|
||||
return value.date().strftime("%Y%m%d")
|
||||
return format_ical_datetime(value)
|
||||
|
||||
|
||||
def format_ical_datetime(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def date_params(event: Any) -> dict[str, list[str]]:
|
||||
if event.all_day:
|
||||
return {"VALUE": ["DATE"]}
|
||||
if event.timezone:
|
||||
return {"TZID": [event.timezone]}
|
||||
return {}
|
||||
|
||||
|
||||
def property_party(prop: ICalendarProperty | None) -> dict[str, Any] | None:
|
||||
if prop is None:
|
||||
return None
|
||||
return {"value": prop.value, "params": prop.params}
|
||||
|
||||
|
||||
def party_to_property(name: str, party: dict[str, Any]) -> tuple[str, dict[str, list[str]], str]:
|
||||
params = party.get("params") if isinstance(party.get("params"), dict) else {}
|
||||
value = str(party.get("value") or party.get("email") or "")
|
||||
normalized_params = {str(key).upper(): [str(item) for item in value] for key, value in params.items() if isinstance(value, list)}
|
||||
return name, normalized_params, value
|
||||
|
||||
|
||||
def parse_key_value_property(value: str | None) -> dict[str, Any] | None:
|
||||
if not value:
|
||||
return None
|
||||
result: dict[str, Any] = {}
|
||||
for item in value.split(";"):
|
||||
key, sep, raw = item.partition("=")
|
||||
if sep:
|
||||
result[key.upper()] = raw.split(",") if "," in raw else raw
|
||||
return result or {"raw": value}
|
||||
|
||||
|
||||
def serialize_key_value_property(value: dict[str, Any]) -> str:
|
||||
parts: list[str] = []
|
||||
for key, item in value.items():
|
||||
if isinstance(item, list):
|
||||
parts.append(f"{key.upper()}={','.join(str(entry) for entry in item)}")
|
||||
else:
|
||||
parts.append(f"{key.upper()}={item}")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def split_csv(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def int_or_zero(value: str | None) -> int:
|
||||
try:
|
||||
return int(value or "0")
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_ics(ics: str) -> str:
|
||||
stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n")
|
||||
return "\r\n".join(stripped.split("\n")) + "\r\n"
|
||||
|
||||
|
||||
def serialize_content_line(name: str, params: dict[str, list[str]], value: str) -> str:
|
||||
param_text = "".join(f";{key}={','.join(escape_param(item) for item in items)}" for key, items in params.items() if items)
|
||||
return f"{name}{param_text}:{escape_text(value)}"
|
||||
|
||||
|
||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||
if len(line) <= limit:
|
||||
return line
|
||||
parts = [line[:limit]]
|
||||
line = line[limit:]
|
||||
while line:
|
||||
parts.append(" " + line[: limit - 1])
|
||||
line = line[limit - 1 :]
|
||||
return "\r\n".join(parts)
|
||||
|
||||
|
||||
def escape_text(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,")
|
||||
|
||||
|
||||
def unescape_text(value: str) -> str:
|
||||
return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\;", ";").replace("\\,", ",").replace("\\\\", "\\")
|
||||
|
||||
|
||||
def escape_param(value: str) -> str:
|
||||
if any(char in value for char in [":", ";", ","]):
|
||||
return '"' + value.replace('"', "'") + '"'
|
||||
return value
|
||||
|
||||
|
||||
def http_last_modified(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return format_datetime(value.astimezone(timezone.utc), usegmt=True)
|
||||
109
src/govoplan_calendar/backend/manifest.py
Normal file
109
src/govoplan_calendar/backend/manifest.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Calendar",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("calendar:calendar:read", "View calendars", "List tenant calendar collections and metadata."),
|
||||
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
|
||||
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
|
||||
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
|
||||
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."),
|
||||
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
|
||||
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
|
||||
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
|
||||
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="calendar_manager",
|
||||
name="Calendar manager",
|
||||
description="Manage tenant calendars and events.",
|
||||
permissions=(
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:write",
|
||||
"calendar:event:read",
|
||||
"calendar:event:write",
|
||||
"calendar:event:delete",
|
||||
"calendar:event:import",
|
||||
"calendar:event:export",
|
||||
"calendar:availability:read",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="calendar_viewer",
|
||||
name="Calendar viewer",
|
||||
description="Read calendars, events, exports, and availability.",
|
||||
permissions=(
|
||||
"calendar:calendar:read",
|
||||
"calendar:event:read",
|
||||
"calendar:event:export",
|
||||
"calendar:availability:read",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
|
||||
|
||||
return {
|
||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
|
||||
def _calendar_router(context: ModuleContext):
|
||||
from govoplan_calendar.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
from govoplan_calendar.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="0.1.4",
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_calendar_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
frontend=FrontendModule(
|
||||
module_id="calendar",
|
||||
package_name="@govoplan/calendar-webui",
|
||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="calendar",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
3
src/govoplan_calendar/backend/migrations/README.md
Normal file
3
src/govoplan_calendar/backend/migrations/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Calendar migrations
|
||||
|
||||
Calendar models are registered through the module manifest migration spec. Place Alembic migration revisions for `govoplan-calendar` in `versions/`.
|
||||
1
src/govoplan_calendar/backend/migrations/__init__.py
Normal file
1
src/govoplan_calendar/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Calendar migration package."""
|
||||
@@ -0,0 +1,167 @@
|
||||
"""calendar collections and VEVENT storage
|
||||
|
||||
Revision ID: 7c8d9e0f1a2b
|
||||
Revises: 1b2c3d4e5f70
|
||||
Create Date: 2026-07-07 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7c8d9e0f1a2b"
|
||||
down_revision = "1b2c3d4e5f70"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_collections" not in tables:
|
||||
op.create_table(
|
||||
"calendar_collections",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("timezone", sa.String(length=100), nullable=False),
|
||||
sa.Column("color", sa.String(length=20), nullable=True),
|
||||
sa.Column("owner_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("owner_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("visibility", sa.String(length=20), nullable=False),
|
||||
sa.Column("is_default", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_collections")),
|
||||
)
|
||||
op.create_index(op.f("ix_calendar_collections_tenant_id"), "calendar_collections", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_created_by_user_id"), "calendar_collections", ["created_by_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_deleted_at"), "calendar_collections", ["deleted_at"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_is_default"), "calendar_collections", ["is_default"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_owner_id"), "calendar_collections", ["owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_owner_type"), "calendar_collections", ["owner_type"], unique=False)
|
||||
op.create_index(op.f("ix_calendar_collections_visibility"), "calendar_collections", ["visibility"], unique=False)
|
||||
op.create_index("ix_calendar_collections_owner", "calendar_collections", ["tenant_id", "owner_type", "owner_id"], unique=False)
|
||||
op.create_index(
|
||||
"uq_calendar_collections_active_slug",
|
||||
"calendar_collections",
|
||||
["tenant_id", "slug"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
if "calendar_events" not in tables:
|
||||
op.create_table(
|
||||
"calendar_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("calendar_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("uid", sa.String(length=255), nullable=False),
|
||||
sa.Column("recurrence_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("summary", sa.String(length=500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("location", sa.String(length=500), nullable=True),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("transparency", sa.String(length=20), nullable=False),
|
||||
sa.Column("classification", sa.String(length=20), nullable=False),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("end_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("all_day", sa.Boolean(), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=100), nullable=True),
|
||||
sa.Column("organizer", sa.JSON(), nullable=True),
|
||||
sa.Column("attendees", sa.JSON(), nullable=False),
|
||||
sa.Column("categories", sa.JSON(), nullable=False),
|
||||
sa.Column("rrule", sa.JSON(), nullable=True),
|
||||
sa.Column("rdate", sa.JSON(), nullable=False),
|
||||
sa.Column("exdate", sa.JSON(), nullable=False),
|
||||
sa.Column("reminders", sa.JSON(), nullable=False),
|
||||
sa.Column("attachments", sa.JSON(), nullable=False),
|
||||
sa.Column("related_to", sa.JSON(), nullable=False),
|
||||
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_href", sa.String(length=1000), nullable=True),
|
||||
sa.Column("etag", sa.String(length=255), nullable=True),
|
||||
sa.Column("icalendar", sa.JSON(), nullable=False),
|
||||
sa.Column("raw_ics", sa.Text(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_events_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
|
||||
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"calendar_id",
|
||||
"uid",
|
||||
"recurrence_id",
|
||||
"status",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"all_day",
|
||||
"source_kind",
|
||||
"etag",
|
||||
"created_by_user_id",
|
||||
"updated_by_user_id",
|
||||
"deleted_at",
|
||||
):
|
||||
op.create_index(op.f(f"ix_calendar_events_{column}"), "calendar_events", [column], unique=False)
|
||||
op.create_index("ix_calendar_events_range", "calendar_events", ["tenant_id", "calendar_id", "start_at", "end_at"], unique=False)
|
||||
op.create_index("ix_calendar_events_tenant_uid", "calendar_events", ["tenant_id", "uid"], unique=False)
|
||||
op.create_index("ix_calendar_events_tenant_status", "calendar_events", ["tenant_id", "status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_events" in tables:
|
||||
for name in (
|
||||
"ix_calendar_events_tenant_status",
|
||||
"ix_calendar_events_tenant_uid",
|
||||
"ix_calendar_events_range",
|
||||
op.f("ix_calendar_events_deleted_at"),
|
||||
op.f("ix_calendar_events_updated_by_user_id"),
|
||||
op.f("ix_calendar_events_created_by_user_id"),
|
||||
op.f("ix_calendar_events_etag"),
|
||||
op.f("ix_calendar_events_source_kind"),
|
||||
op.f("ix_calendar_events_all_day"),
|
||||
op.f("ix_calendar_events_end_at"),
|
||||
op.f("ix_calendar_events_start_at"),
|
||||
op.f("ix_calendar_events_status"),
|
||||
op.f("ix_calendar_events_recurrence_id"),
|
||||
op.f("ix_calendar_events_uid"),
|
||||
op.f("ix_calendar_events_calendar_id"),
|
||||
op.f("ix_calendar_events_tenant_id"),
|
||||
):
|
||||
op.drop_index(name, table_name="calendar_events")
|
||||
op.drop_table("calendar_events")
|
||||
if "calendar_collections" in tables:
|
||||
for name in (
|
||||
"uq_calendar_collections_active_slug",
|
||||
"ix_calendar_collections_owner",
|
||||
op.f("ix_calendar_collections_visibility"),
|
||||
op.f("ix_calendar_collections_owner_type"),
|
||||
op.f("ix_calendar_collections_owner_id"),
|
||||
op.f("ix_calendar_collections_is_default"),
|
||||
op.f("ix_calendar_collections_deleted_at"),
|
||||
op.f("ix_calendar_collections_created_by_user_id"),
|
||||
op.f("ix_calendar_collections_tenant_id"),
|
||||
):
|
||||
op.drop_index(name, table_name="calendar_collections")
|
||||
op.drop_table("calendar_collections")
|
||||
@@ -0,0 +1 @@
|
||||
"""Calendar Alembic revisions."""
|
||||
244
src/govoplan_calendar/backend/router.py
Normal file
244
src/govoplan_calendar/backend/router.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCollectionCreateRequest,
|
||||
CalendarCollectionListResponse,
|
||||
CalendarCollectionResponse,
|
||||
CalendarCollectionUpdateRequest,
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventListResponse,
|
||||
CalendarEventResponse,
|
||||
CalendarEventUpdateRequest,
|
||||
CalendarIcsImportRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
calendar_response,
|
||||
create_calendar,
|
||||
create_event,
|
||||
delete_calendar,
|
||||
delete_event,
|
||||
event_response,
|
||||
get_event,
|
||||
import_ics_event,
|
||||
list_calendars,
|
||||
list_events,
|
||||
update_calendar,
|
||||
update_event,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(has_scope(principal, scope) for scope in scopes):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
|
||||
|
||||
|
||||
def _calendar_response(calendar) -> CalendarCollectionResponse:
|
||||
return CalendarCollectionResponse.model_validate(calendar_response(calendar))
|
||||
|
||||
|
||||
def _event_response(event) -> CalendarEventResponse:
|
||||
return CalendarEventResponse.model_validate(event_response(event))
|
||||
|
||||
|
||||
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||
def api_list_calendars(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:read")
|
||||
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
|
||||
session.commit()
|
||||
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
|
||||
|
||||
|
||||
@router.post("/calendars", response_model=CalendarCollectionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_create_calendar(
|
||||
payload: CalendarCollectionCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:write")
|
||||
try:
|
||||
calendar = create_calendar(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(calendar)
|
||||
return _calendar_response(calendar)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
|
||||
def api_update_calendar(
|
||||
calendar_id: str,
|
||||
payload: CalendarCollectionUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:write")
|
||||
try:
|
||||
calendar = update_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(calendar)
|
||||
return _calendar_response(calendar)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_calendar(
|
||||
calendar_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/events", response_model=CalendarEventListResponse)
|
||||
def api_list_events(
|
||||
calendar_id: str | None = None,
|
||||
start_at: datetime | None = Query(default=None),
|
||||
end_at: datetime | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||
return CalendarEventListResponse(events=[_event_response(event) for event in events])
|
||||
|
||||
|
||||
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_create_event(
|
||||
payload: CalendarEventCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:write")
|
||||
try:
|
||||
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
|
||||
def api_get_event(
|
||||
event_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
try:
|
||||
return _event_response(get_event(session, tenant_id=principal.tenant_id, event_id=event_id))
|
||||
except CalendarError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}", response_model=CalendarEventResponse)
|
||||
def api_update_event(
|
||||
event_id: str,
|
||||
payload: CalendarEventUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:write")
|
||||
try:
|
||||
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_event(
|
||||
event_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
|
||||
try:
|
||||
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_import_ics_event(
|
||||
payload: CalendarIcsImportRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:import")
|
||||
try:
|
||||
event = import_ics_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
calendar_id=payload.calendar_id,
|
||||
ics=payload.ics,
|
||||
upsert=payload.upsert,
|
||||
source_kind=payload.source_kind,
|
||||
source_href=payload.source_href,
|
||||
etag=payload.etag,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except (CalendarError, ICalendarError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/ics")
|
||||
def api_export_ics_event(
|
||||
event_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:export")
|
||||
try:
|
||||
event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id)
|
||||
except CalendarError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
ics = event.raw_ics or event_to_ics(event)
|
||||
return Response(
|
||||
content=ics,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{event.uid}.ics"',
|
||||
"Last-Modified": http_last_modified(event.updated_at),
|
||||
},
|
||||
)
|
||||
36
src/govoplan_calendar/backend/runtime.py
Normal file
36
src/govoplan_calendar/backend/runtime.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_runtime_registry: object | None = None
|
||||
_runtime_settings: object | None = None
|
||||
|
||||
|
||||
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||
global _runtime_registry, _runtime_settings
|
||||
if registry is not None:
|
||||
_runtime_registry = registry
|
||||
if settings is not None:
|
||||
_runtime_settings = settings
|
||||
|
||||
|
||||
def get_registry() -> object | None:
|
||||
return _runtime_registry
|
||||
|
||||
|
||||
def get_settings() -> object:
|
||||
if _runtime_settings is not None:
|
||||
return _runtime_settings
|
||||
try:
|
||||
from govoplan_core.settings import settings as legacy_settings
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc
|
||||
return legacy_settings
|
||||
|
||||
|
||||
class SettingsProxy:
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(get_settings(), name)
|
||||
|
||||
|
||||
settings = SettingsProxy()
|
||||
175
src/govoplan_calendar/backend/schemas.py
Normal file
175
src/govoplan_calendar/backend/schemas.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
||||
|
||||
|
||||
class CalendarCollectionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
timezone: str = Field(default="UTC", max_length=100)
|
||||
color: str | None = Field(default=None, max_length=20)
|
||||
owner_type: CalendarOwnerType = "tenant"
|
||||
owner_id: str | None = None
|
||||
visibility: CalendarVisibility = "tenant"
|
||||
is_default: bool = False
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarCollectionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
color: str | None = Field(default=None, max_length=20)
|
||||
visibility: CalendarVisibility | None = None
|
||||
is_default: bool | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarCollectionResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
timezone: str
|
||||
color: str | None = None
|
||||
owner_type: str
|
||||
owner_id: str | None = None
|
||||
visibility: str
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarCollectionListResponse(BaseModel):
|
||||
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarEventCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calendar_id: str | None = None
|
||||
uid: str | None = Field(default=None, max_length=255)
|
||||
recurrence_id: str | None = Field(default=None, max_length=255)
|
||||
sequence: int = 0
|
||||
summary: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
status: str = Field(default="CONFIRMED", max_length=40)
|
||||
transparency: str = Field(default="OPAQUE", max_length=20)
|
||||
classification: str = Field(default="PUBLIC", max_length=20)
|
||||
start_at: datetime
|
||||
end_at: datetime | None = None
|
||||
duration_seconds: int | None = None
|
||||
all_day: bool = False
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
organizer: dict[str, Any] | None = None
|
||||
attendees: list[dict[str, Any]] = Field(default_factory=list)
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
rrule: dict[str, Any] | None = None
|
||||
rdate: list[dict[str, Any]] = Field(default_factory=list)
|
||||
exdate: list[dict[str, Any]] = Field(default_factory=list)
|
||||
reminders: list[dict[str, Any]] = Field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
related_to: list[dict[str, Any]] = Field(default_factory=list)
|
||||
source_kind: str = Field(default="local", max_length=30)
|
||||
source_href: str | None = Field(default=None, max_length=1000)
|
||||
etag: str | None = Field(default=None, max_length=255)
|
||||
icalendar: dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarEventUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calendar_id: str | None = None
|
||||
sequence: int | None = None
|
||||
summary: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
status: str | None = Field(default=None, max_length=40)
|
||||
transparency: str | None = Field(default=None, max_length=20)
|
||||
classification: str | None = Field(default=None, max_length=20)
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
duration_seconds: int | None = None
|
||||
all_day: bool | None = None
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
organizer: dict[str, Any] | None = None
|
||||
attendees: list[dict[str, Any]] | None = None
|
||||
categories: list[str] | None = None
|
||||
rrule: dict[str, Any] | None = None
|
||||
rdate: list[dict[str, Any]] | None = None
|
||||
exdate: list[dict[str, Any]] | None = None
|
||||
reminders: list[dict[str, Any]] | None = None
|
||||
attachments: list[dict[str, Any]] | None = None
|
||||
related_to: list[dict[str, Any]] | None = None
|
||||
source_kind: str | None = Field(default=None, max_length=30)
|
||||
source_href: str | None = Field(default=None, max_length=1000)
|
||||
etag: str | None = Field(default=None, max_length=255)
|
||||
icalendar: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
calendar_id: str
|
||||
uid: str
|
||||
recurrence_id: str | None = None
|
||||
sequence: int
|
||||
summary: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
status: str
|
||||
transparency: str
|
||||
classification: str
|
||||
start_at: datetime
|
||||
end_at: datetime | None = None
|
||||
duration_seconds: int | None = None
|
||||
all_day: bool
|
||||
timezone: str | None = None
|
||||
organizer: dict[str, Any] | None = None
|
||||
attendees: list[dict[str, Any]] = Field(default_factory=list)
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
rrule: dict[str, Any] | None = None
|
||||
rdate: list[dict[str, Any]] = Field(default_factory=list)
|
||||
exdate: list[dict[str, Any]] = Field(default_factory=list)
|
||||
reminders: list[dict[str, Any]] = Field(default_factory=list)
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
related_to: list[dict[str, Any]] = Field(default_factory=list)
|
||||
source_kind: str
|
||||
source_href: str | None = None
|
||||
etag: str | None = None
|
||||
icalendar: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarEventListResponse(BaseModel):
|
||||
events: list[CalendarEventResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarIcsImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calendar_id: str | None = None
|
||||
ics: str = Field(min_length=1)
|
||||
upsert: bool = True
|
||||
source_kind: str = Field(default="ical", max_length=30)
|
||||
source_href: str | None = Field(default=None, max_length=1000)
|
||||
etag: str | None = Field(default=None, max_length=255)
|
||||
383
src/govoplan_calendar/backend/service.py
Normal file
383
src/govoplan_calendar/backend/service.py
Normal file
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
|
||||
from govoplan_calendar.backend.ical import parse_vevent
|
||||
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarCollectionUpdateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||
from govoplan_core.db.base import utcnow
|
||||
|
||||
|
||||
class CalendarError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or "calendar"
|
||||
|
||||
|
||||
def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection:
|
||||
existing = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None))
|
||||
.order_by(CalendarCollection.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
calendar = CalendarCollection(
|
||||
tenant_id=tenant_id,
|
||||
slug="default",
|
||||
name="Calendar",
|
||||
timezone="UTC",
|
||||
color="#0f766e",
|
||||
owner_type="tenant",
|
||||
owner_id=None,
|
||||
visibility="tenant",
|
||||
is_default=True,
|
||||
created_by_user_id=user_id,
|
||||
metadata_={},
|
||||
)
|
||||
session.add(calendar)
|
||||
session.flush()
|
||||
return calendar
|
||||
|
||||
|
||||
def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = True, user_id: str | None = None) -> list[CalendarCollection]:
|
||||
if ensure_default:
|
||||
ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id)
|
||||
return (
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None))
|
||||
.order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCollectionCreateRequest) -> CalendarCollection:
|
||||
slug = payload.slug or slugify(payload.name)
|
||||
if calendar_slug_exists(session, tenant_id=tenant_id, slug=slug):
|
||||
raise CalendarError(f"Calendar slug already exists: {slug}")
|
||||
if payload.is_default:
|
||||
clear_default_calendar(session, tenant_id=tenant_id)
|
||||
calendar = CalendarCollection(
|
||||
tenant_id=tenant_id,
|
||||
slug=slug,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
timezone=payload.timezone,
|
||||
color=payload.color,
|
||||
owner_type=payload.owner_type,
|
||||
owner_id=payload.owner_id,
|
||||
visibility=payload.visibility,
|
||||
is_default=payload.is_default,
|
||||
created_by_user_id=user_id,
|
||||
metadata_=payload.metadata,
|
||||
)
|
||||
session.add(calendar)
|
||||
session.flush()
|
||||
return calendar
|
||||
|
||||
|
||||
def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionUpdateRequest) -> CalendarCollection:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
if payload.is_default is True:
|
||||
clear_default_calendar(session, tenant_id=tenant_id)
|
||||
calendar.is_default = True
|
||||
elif payload.is_default is False:
|
||||
calendar.is_default = False
|
||||
for field in ("name", "description", "timezone", "color", "visibility"):
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(calendar, field, value)
|
||||
if payload.metadata is not None:
|
||||
calendar.metadata_ = payload.metadata
|
||||
session.flush()
|
||||
return calendar
|
||||
|
||||
|
||||
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> None:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
if calendar.is_default:
|
||||
raise CalendarError("Default calendar cannot be deleted")
|
||||
calendar.deleted_at = utcnow()
|
||||
for event in calendar.events:
|
||||
if event.deleted_at is None:
|
||||
event.deleted_at = calendar.deleted_at
|
||||
session.flush()
|
||||
|
||||
|
||||
def get_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarCollection:
|
||||
calendar = (
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.id == calendar_id, CalendarCollection.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if not calendar:
|
||||
raise CalendarError("Calendar not found")
|
||||
return calendar
|
||||
|
||||
|
||||
def calendar_slug_exists(session: Session, *, tenant_id: str, slug: str) -> bool:
|
||||
return (
|
||||
session.query(CalendarCollection.id)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.slug == slug, CalendarCollection.deleted_at.is_(None))
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def clear_default_calendar(session: Session, *, tenant_id: str) -> None:
|
||||
for calendar in (
|
||||
session.query(CalendarCollection)
|
||||
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None))
|
||||
.all()
|
||||
):
|
||||
calendar.is_default = False
|
||||
|
||||
|
||||
def list_events(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
calendar_id: str | None = None,
|
||||
start_at: datetime | None = None,
|
||||
end_at: datetime | None = None,
|
||||
) -> list[CalendarEvent]:
|
||||
query = session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None))
|
||||
if calendar_id:
|
||||
query = query.filter(CalendarEvent.calendar_id == calendar_id)
|
||||
if start_at is not None:
|
||||
query = query.filter(or_(CalendarEvent.end_at.is_(None), CalendarEvent.end_at >= normalize_datetime(start_at)))
|
||||
if end_at is not None:
|
||||
query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at))
|
||||
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
|
||||
|
||||
|
||||
def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEvent:
|
||||
event = (
|
||||
session.query(CalendarEvent)
|
||||
.filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.id == event_id, CalendarEvent.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if not event:
|
||||
raise CalendarError("Calendar event not found")
|
||||
return event
|
||||
|
||||
|
||||
def create_event(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarEventCreateRequest) -> CalendarEvent:
|
||||
calendar_id = payload.calendar_id or ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id).id
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
event = CalendarEvent(
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
uid=payload.uid or f"{uuid.uuid4()}@govoplan.local",
|
||||
recurrence_id=payload.recurrence_id,
|
||||
sequence=payload.sequence,
|
||||
summary=payload.summary,
|
||||
description=payload.description,
|
||||
location=payload.location,
|
||||
status=payload.status.upper(),
|
||||
transparency=payload.transparency.upper(),
|
||||
classification=payload.classification.upper(),
|
||||
start_at=normalize_datetime(payload.start_at),
|
||||
end_at=normalize_datetime(payload.end_at) if payload.end_at else None,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
all_day=payload.all_day,
|
||||
timezone=payload.timezone,
|
||||
organizer=payload.organizer,
|
||||
attendees=payload.attendees,
|
||||
categories=payload.categories,
|
||||
rrule=payload.rrule,
|
||||
rdate=payload.rdate,
|
||||
exdate=payload.exdate,
|
||||
reminders=payload.reminders,
|
||||
attachments=payload.attachments,
|
||||
related_to=payload.related_to,
|
||||
source_kind=payload.source_kind,
|
||||
source_href=payload.source_href,
|
||||
etag=payload.etag,
|
||||
icalendar=payload.icalendar,
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
metadata_=payload.metadata,
|
||||
)
|
||||
validate_event_time(event)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return event
|
||||
|
||||
|
||||
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
if payload.calendar_id is not None:
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
event.calendar_id = payload.calendar_id
|
||||
scalar_fields = (
|
||||
"sequence",
|
||||
"summary",
|
||||
"description",
|
||||
"location",
|
||||
"status",
|
||||
"transparency",
|
||||
"classification",
|
||||
"duration_seconds",
|
||||
"all_day",
|
||||
"timezone",
|
||||
"organizer",
|
||||
"attendees",
|
||||
"categories",
|
||||
"rrule",
|
||||
"rdate",
|
||||
"exdate",
|
||||
"reminders",
|
||||
"attachments",
|
||||
"related_to",
|
||||
"source_kind",
|
||||
"source_href",
|
||||
"etag",
|
||||
"icalendar",
|
||||
)
|
||||
for field in scalar_fields:
|
||||
value = getattr(payload, field)
|
||||
if value is not None:
|
||||
setattr(event, field, value.upper() if field in {"status", "transparency", "classification"} and isinstance(value, str) else value)
|
||||
if payload.start_at is not None:
|
||||
event.start_at = normalize_datetime(payload.start_at)
|
||||
if "end_at" in payload.model_fields_set:
|
||||
event.end_at = normalize_datetime(payload.end_at) if payload.end_at else None
|
||||
if payload.metadata is not None:
|
||||
event.metadata_ = payload.metadata
|
||||
event.updated_by_user_id = user_id
|
||||
if payload.sequence is None:
|
||||
event.sequence += 1
|
||||
validate_event_time(event)
|
||||
session.flush()
|
||||
return event
|
||||
|
||||
|
||||
def delete_event(session: Session, *, tenant_id: str, event_id: str) -> None:
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
event.deleted_at = utcnow()
|
||||
session.flush()
|
||||
|
||||
|
||||
def import_ics_event(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
calendar_id: str | None,
|
||||
ics: str,
|
||||
upsert: bool,
|
||||
source_kind: str,
|
||||
source_href: str | None,
|
||||
etag: str | None,
|
||||
) -> CalendarEvent:
|
||||
parsed = parse_vevent(ics)
|
||||
raw_ics = str(parsed.pop("raw_ics", ""))
|
||||
target_calendar_id = calendar_id or ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id).id
|
||||
existing = None
|
||||
if upsert:
|
||||
existing = (
|
||||
session.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.calendar_id == target_calendar_id,
|
||||
CalendarEvent.uid == parsed["uid"],
|
||||
CalendarEvent.recurrence_id == parsed.get("recurrence_id"),
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
payload = CalendarEventCreateRequest(
|
||||
calendar_id=target_calendar_id,
|
||||
source_kind=source_kind,
|
||||
source_href=source_href,
|
||||
etag=etag,
|
||||
metadata={},
|
||||
**parsed,
|
||||
)
|
||||
if existing:
|
||||
update_payload = CalendarEventUpdateRequest(**payload.model_dump(exclude={"uid", "recurrence_id"}))
|
||||
event = update_event(session, tenant_id=tenant_id, user_id=user_id, event_id=existing.id, payload=update_payload)
|
||||
event.raw_ics = raw_ics
|
||||
return event
|
||||
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
|
||||
event.raw_ics = raw_ics
|
||||
return event
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_event_time(event: CalendarEvent) -> None:
|
||||
if event.end_at is not None and event.end_at < event.start_at:
|
||||
raise CalendarError("Event end must be after start")
|
||||
|
||||
|
||||
def calendar_response(calendar: CalendarCollection) -> dict[str, Any]:
|
||||
return {
|
||||
"id": calendar.id,
|
||||
"tenant_id": calendar.tenant_id,
|
||||
"slug": calendar.slug,
|
||||
"name": calendar.name,
|
||||
"description": calendar.description,
|
||||
"timezone": calendar.timezone,
|
||||
"color": calendar.color,
|
||||
"owner_type": calendar.owner_type,
|
||||
"owner_id": calendar.owner_id,
|
||||
"visibility": calendar.visibility,
|
||||
"is_default": calendar.is_default,
|
||||
"created_at": calendar.created_at,
|
||||
"updated_at": calendar.updated_at,
|
||||
"metadata": calendar.metadata_ or {},
|
||||
}
|
||||
|
||||
|
||||
def event_response(event: CalendarEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"id": event.id,
|
||||
"tenant_id": event.tenant_id,
|
||||
"calendar_id": event.calendar_id,
|
||||
"uid": event.uid,
|
||||
"recurrence_id": event.recurrence_id,
|
||||
"sequence": event.sequence,
|
||||
"summary": event.summary,
|
||||
"description": event.description,
|
||||
"location": event.location,
|
||||
"status": event.status,
|
||||
"transparency": event.transparency,
|
||||
"classification": event.classification,
|
||||
"start_at": event.start_at,
|
||||
"end_at": event.end_at,
|
||||
"duration_seconds": event.duration_seconds,
|
||||
"all_day": event.all_day,
|
||||
"timezone": event.timezone,
|
||||
"organizer": event.organizer,
|
||||
"attendees": event.attendees or [],
|
||||
"categories": event.categories or [],
|
||||
"rrule": event.rrule,
|
||||
"rdate": event.rdate or [],
|
||||
"exdate": event.exdate or [],
|
||||
"reminders": event.reminders or [],
|
||||
"attachments": event.attachments or [],
|
||||
"related_to": event.related_to or [],
|
||||
"source_kind": event.source_kind,
|
||||
"source_href": event.source_href,
|
||||
"etag": event.etag,
|
||||
"icalendar": event.icalendar or {},
|
||||
"created_at": event.created_at,
|
||||
"updated_at": event.updated_at,
|
||||
"metadata": event.metadata_ or {},
|
||||
}
|
||||
1
src/govoplan_calendar/py.typed
Normal file
1
src/govoplan_calendar/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user