Initialize GovOPlaN calendar module
This commit is contained in:
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 {},
|
||||
}
|
||||
Reference in New Issue
Block a user