diff --git a/README.md b/README.md index 627781b..e811936 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # govoplan-calendar + +**Repository type:** module (domain). + + GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware. ## Ownership diff --git a/package.json b/package.json index cbd0403..7656e38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.7", + "@govoplan/core-webui": "^0.1.8", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pyproject.toml b/pyproject.toml index ff7c0fd..2932bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-calendar" -version = "0.1.7" +version = "0.1.8" description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.7", - "govoplan-access>=0.1.7", + "govoplan-core>=0.1.8", + "govoplan-access>=0.1.8", + "defusedxml>=0.7,<1", "icalendar>=7.2", "python-dateutil>=2.9", ] diff --git a/src/govoplan_calendar/backend/caldav.py b/src/govoplan_calendar/backend/caldav.py index 7a19d98..56f6958 100644 --- a/src/govoplan_calendar/backend/caldav.py +++ b/src/govoplan_calendar/backend/caldav.py @@ -8,6 +8,8 @@ from dataclasses import dataclass, field from typing import Mapping, Protocol from xml.etree import ElementTree +from defusedxml import ElementTree as SafeElementTree + class CalDAVError(RuntimeError): pass @@ -322,8 +324,8 @@ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: by def parse_multistatus(payload: bytes) -> CalDAVReportResult: try: - root = ElementTree.fromstring(payload) - except ElementTree.ParseError as exc: + root = SafeElementTree.fromstring(payload) + except SafeElementTree.ParseError as exc: raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc objects: list[CalDAVObject] = [] sync_token = first_child_text(root, "sync-token") @@ -355,8 +357,8 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult: def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]: try: - root = ElementTree.fromstring(payload) - except ElementTree.ParseError as exc: + root = SafeElementTree.fromstring(payload) + except SafeElementTree.ParseError as exc: raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc responses: list[_DAVDiscoveryResponse] = [] for response in child_elements(root, "response"): diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 9a57700..978a527 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -88,7 +88,7 @@ def _calendar_router(context: ModuleContext): manifest = ModuleManifest( id="calendar", name="Calendar", - version="0.1.7", + version="0.1.8", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), permissions=PERMISSIONS, diff --git a/src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py b/src/govoplan_calendar/backend/migrations/dev_versions/7c8d9e0f1a2b_calendar_collections_events.py similarity index 100% rename from src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py rename to src/govoplan_calendar/backend/migrations/dev_versions/7c8d9e0f1a2b_calendar_collections_events.py diff --git a/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py b/src/govoplan_calendar/backend/migrations/dev_versions/8d9e0f1a2b3c_caldav_sync_sources.py similarity index 100% rename from src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py rename to src/govoplan_calendar/backend/migrations/dev_versions/8d9e0f1a2b3c_caldav_sync_sources.py diff --git a/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py b/src/govoplan_calendar/backend/migrations/dev_versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py similarity index 100% rename from src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py rename to src/govoplan_calendar/backend/migrations/dev_versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py diff --git a/src/govoplan_calendar/backend/migrations/dev_versions/__init__.py b/src/govoplan_calendar/backend/migrations/dev_versions/__init__.py new file mode 100644 index 0000000..34eed4d --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/dev_versions/__init__.py @@ -0,0 +1 @@ +"""Calendar Alembic revisions.""" diff --git a/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_v017_calendar_baseline.py b/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_v017_calendar_baseline.py new file mode 100644 index 0000000..84e44d8 --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_v017_calendar_baseline.py @@ -0,0 +1,174 @@ +"""v0.1.7 calendar baseline + +Revision ID: 9e0f1a2b3c4d +Revises: None +Create Date: 2026-07-11 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '9e0f1a2b3c4d' +down_revision = None +branch_labels = None +depends_on = '4f2a9c8e7b6d' + + +def upgrade() -> None: + 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'], ['access_users.id'], name=op.f('fk_calendar_collections_created_by_user_id_access_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_collections_tenant_id_scopes'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_collections')) + ) + 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('ix_calendar_collections_owner', 'calendar_collections', ['tenant_id', 'owner_type', 'owner_id'], 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_tenant_id'), 'calendar_collections', ['tenant_id'], unique=False) + op.create_index(op.f('ix_calendar_collections_visibility'), 'calendar_collections', ['visibility'], 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')) + op.create_table('calendar_sync_credentials', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('credential_kind', sa.String(length=30), nullable=False), + sa.Column('label', sa.String(length=255), nullable=True), + sa.Column('secret_encrypted', sa.Text(), nullable=True), + 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'], ['access_users.id'], name=op.f('fk_calendar_sync_credentials_created_by_user_id_access_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_credentials_tenant_id_scopes'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_credentials')) + ) + op.create_index(op.f('ix_calendar_sync_credentials_created_by_user_id'), 'calendar_sync_credentials', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_calendar_sync_credentials_credential_kind'), 'calendar_sync_credentials', ['credential_kind'], unique=False) + op.create_index(op.f('ix_calendar_sync_credentials_deleted_at'), 'calendar_sync_credentials', ['deleted_at'], unique=False) + op.create_index(op.f('ix_calendar_sync_credentials_tenant_id'), 'calendar_sync_credentials', ['tenant_id'], unique=False) + op.create_index('ix_calendar_sync_credentials_tenant_kind', 'calendar_sync_credentials', ['tenant_id', 'credential_kind'], unique=False) + 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'], ['access_users.id'], name=op.f('fk_calendar_events_created_by_user_id_access_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_events_tenant_id_scopes'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_updated_by_user_id_access_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') + ) + op.create_index(op.f('ix_calendar_events_all_day'), 'calendar_events', ['all_day'], unique=False) + op.create_index(op.f('ix_calendar_events_calendar_id'), 'calendar_events', ['calendar_id'], unique=False) + op.create_index(op.f('ix_calendar_events_created_by_user_id'), 'calendar_events', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_calendar_events_deleted_at'), 'calendar_events', ['deleted_at'], unique=False) + op.create_index(op.f('ix_calendar_events_end_at'), 'calendar_events', ['end_at'], unique=False) + op.create_index(op.f('ix_calendar_events_etag'), 'calendar_events', ['etag'], unique=False) + op.create_index('ix_calendar_events_range', 'calendar_events', ['tenant_id', 'calendar_id', 'start_at', 'end_at'], unique=False) + op.create_index(op.f('ix_calendar_events_recurrence_id'), 'calendar_events', ['recurrence_id'], unique=False) + op.create_index(op.f('ix_calendar_events_source_kind'), 'calendar_events', ['source_kind'], unique=False) + op.create_index(op.f('ix_calendar_events_start_at'), 'calendar_events', ['start_at'], unique=False) + op.create_index(op.f('ix_calendar_events_status'), 'calendar_events', ['status'], unique=False) + op.create_index(op.f('ix_calendar_events_tenant_id'), 'calendar_events', ['tenant_id'], unique=False) + op.create_index('ix_calendar_events_tenant_status', 'calendar_events', ['tenant_id', 'status'], unique=False) + op.create_index('ix_calendar_events_tenant_uid', 'calendar_events', ['tenant_id', 'uid'], unique=False) + op.create_index(op.f('ix_calendar_events_uid'), 'calendar_events', ['uid'], unique=False) + op.create_index(op.f('ix_calendar_events_updated_by_user_id'), 'calendar_events', ['updated_by_user_id'], unique=False) + op.create_table('calendar_sync_sources', + 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('source_kind', sa.String(length=30), nullable=False), + sa.Column('collection_url', sa.String(length=1000), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('auth_type', sa.String(length=30), nullable=False), + sa.Column('username', sa.String(length=255), nullable=True), + sa.Column('credential_ref', sa.String(length=255), nullable=True), + sa.Column('sync_enabled', sa.Boolean(), nullable=False), + sa.Column('sync_interval_seconds', sa.Integer(), nullable=False), + sa.Column('sync_direction', sa.String(length=30), nullable=False), + sa.Column('conflict_policy', sa.String(length=30), nullable=False), + sa.Column('sync_token', sa.Text(), nullable=True), + sa.Column('ctag', sa.String(length=255), nullable=True), + sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('next_sync_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_status', sa.String(length=30), nullable=True), + sa.Column('last_error', sa.Text(), 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_sync_sources_calendar_id_calendar_collections'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_sources_tenant_id_scopes'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_sources')) + ) + op.create_index('ix_calendar_sync_sources_calendar', 'calendar_sync_sources', ['tenant_id', 'calendar_id', 'source_kind'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_calendar_id'), 'calendar_sync_sources', ['calendar_id'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_deleted_at'), 'calendar_sync_sources', ['deleted_at'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_next_sync_at'), 'calendar_sync_sources', ['next_sync_at'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_source_kind'), 'calendar_sync_sources', ['source_kind'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_sync_enabled'), 'calendar_sync_sources', ['sync_enabled'], unique=False) + op.create_index(op.f('ix_calendar_sync_sources_tenant_id'), 'calendar_sync_sources', ['tenant_id'], unique=False) + op.create_index('uq_calendar_sync_sources_active_url', 'calendar_sync_sources', ['tenant_id', 'source_kind', 'collection_url'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL')) + + +def downgrade() -> None: + op.drop_table('calendar_sync_sources') + op.drop_table('calendar_events') + op.drop_table('calendar_sync_credentials') + op.drop_table('calendar_collections') diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index d329cba..38ac6d9 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -13,6 +13,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, Callable from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from defusedxml import ElementTree as SafeElementTree from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -1375,8 +1376,8 @@ def ews_find_item_body(*, start: datetime, end: datetime, mailbox: Any | None = def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]: try: - root = ET.fromstring(xml_text) - except ET.ParseError as exc: + root = SafeElementTree.fromstring(xml_text) + except SafeElementTree.ParseError as exc: raise CalendarError(f"Invalid EWS response XML: {exc}") from exc items: list[dict[str, Any]] = [] for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): diff --git a/webui/package.json b/webui/package.json index 4cbd598..59b4886 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "main": "src/index.ts", @@ -14,7 +14,7 @@ "./styles/calendar.css": "./src/styles/calendar.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.7", + "@govoplan/core-webui": "^0.1.8", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0",