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