Initialize GovOPlaN calendar module

This commit is contained in:
2026-07-07 02:49:44 +02:00
commit 3bcb04ff76
36 changed files with 3410 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""GovOPlaN calendar module."""
__all__ = ["__version__"]
__version__ = "0.1.4"

View File

@@ -0,0 +1 @@
"""Backend package for the GovOPlaN calendar module."""

View File

@@ -0,0 +1 @@
"""Calendar database models."""

View 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"]

View 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)

View 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

View 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/`.

View File

@@ -0,0 +1 @@
"""Calendar migration package."""

View File

@@ -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")

View File

@@ -0,0 +1 @@
"""Calendar Alembic revisions."""

View 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),
},
)

View 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()

View 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)

View 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 {},
}

View File

@@ -0,0 +1 @@