Release v0.1.8

This commit is contained in:
2026-07-11 16:49:01 +02:00
parent 39d2c28ab1
commit 9bcf41bb1f
12 changed files with 197 additions and 14 deletions

View File

@@ -1,5 +1,9 @@
# govoplan-calendar # govoplan-calendar
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
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. 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 ## Ownership

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.7", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-calendar" name = "govoplan-calendar"
version = "0.1.7" version = "0.1.8"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.7", "govoplan-core>=0.1.8",
"govoplan-access>=0.1.7", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",
"python-dateutil>=2.9", "python-dateutil>=2.9",
] ]

View File

@@ -8,6 +8,8 @@ from dataclasses import dataclass, field
from typing import Mapping, Protocol from typing import Mapping, Protocol
from xml.etree import ElementTree from xml.etree import ElementTree
from defusedxml import ElementTree as SafeElementTree
class CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
pass pass
@@ -322,8 +324,8 @@ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: by
def parse_multistatus(payload: bytes) -> CalDAVReportResult: def parse_multistatus(payload: bytes) -> CalDAVReportResult:
try: try:
root = ElementTree.fromstring(payload) root = SafeElementTree.fromstring(payload)
except ElementTree.ParseError as exc: except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
objects: list[CalDAVObject] = [] objects: list[CalDAVObject] = []
sync_token = first_child_text(root, "sync-token") 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]: def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try: try:
root = ElementTree.fromstring(payload) root = SafeElementTree.fromstring(payload)
except ElementTree.ParseError as exc: except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
responses: list[_DAVDiscoveryResponse] = [] responses: list[_DAVDiscoveryResponse] = []
for response in child_elements(root, "response"): for response in child_elements(root, "response"):

View File

@@ -88,7 +88,7 @@ def _calendar_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="calendar", id="calendar",
name="Calendar", name="Calendar",
version="0.1.7", version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
permissions=PERMISSIONS, permissions=PERMISSIONS,

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from defusedxml import ElementTree as SafeElementTree
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session 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]]: def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
try: try:
root = ET.fromstring(xml_text) root = SafeElementTree.fromstring(xml_text)
except ET.ParseError as exc: except SafeElementTree.ParseError as exc:
raise CalendarError(f"Invalid EWS response XML: {exc}") from exc raise CalendarError(f"Invalid EWS response XML: {exc}") from exc
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/calendar.css": "./src/styles/calendar.css" "./styles/calendar.css": "./src/styles/calendar.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.7", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",