refactor: harden calendar sync and add dashboard widget
This commit is contained in:
@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -204,6 +205,15 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="calendar.widget.upcoming",
|
||||||
|
module_id="calendar",
|
||||||
|
kind="section",
|
||||||
|
label="Upcoming events widget",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="calendar",
|
module_id="calendar",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from govoplan_core.security.credential_envelopes import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
||||||
from govoplan_calendar.backend.runtime import get_registry
|
from govoplan_calendar.backend.runtime import get_registry
|
||||||
@@ -74,6 +74,7 @@ CALENDAR_MODULE_ID = "calendar"
|
|||||||
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
||||||
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||||
|
REMOTE_SYNC_MAX_ITEMS = 10_000
|
||||||
|
|
||||||
|
|
||||||
def calendar_credential_context(
|
def calendar_credential_context(
|
||||||
@@ -169,6 +170,36 @@ class CalendarCalDavDueSyncResult:
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CalDAVSyncSnapshot:
|
||||||
|
source_id: str
|
||||||
|
tenant_id: str
|
||||||
|
calendar_id: str
|
||||||
|
collection_url: str
|
||||||
|
auth_type: str
|
||||||
|
username: str | None
|
||||||
|
credential_ref: str | None
|
||||||
|
sync_direction: str
|
||||||
|
conflict_policy: str
|
||||||
|
sync_token: str | None
|
||||||
|
ctag: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RemoteSyncSnapshot:
|
||||||
|
source_id: str
|
||||||
|
tenant_id: str
|
||||||
|
calendar_id: str
|
||||||
|
source_kind: str
|
||||||
|
collection_url: str
|
||||||
|
auth_type: str
|
||||||
|
username: str | None
|
||||||
|
credential_ref: str | None
|
||||||
|
sync_token: str | None
|
||||||
|
ctag: str | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str) -> str:
|
def slugify(value: str) -> str:
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
return slug or "calendar"
|
return slug or "calendar"
|
||||||
@@ -1963,8 +1994,16 @@ def sync_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
force_full: bool = False,
|
force_full: bool = False,
|
||||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
# Sync is an explicit unit-of-work boundary. Commit configuration changes
|
||||||
if source.source_kind == "caldav":
|
# and release any request/authentication transaction before remote I/O.
|
||||||
|
session.commit()
|
||||||
|
with Session(bind=session.get_bind()) as preparation_session:
|
||||||
|
source_kind = get_sync_source(
|
||||||
|
preparation_session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
).source_kind
|
||||||
|
if source_kind == "caldav":
|
||||||
return sync_caldav_source(
|
return sync_caldav_source(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -1974,14 +2013,13 @@ def sync_source(
|
|||||||
bearer_token=bearer_token,
|
bearer_token=bearer_token,
|
||||||
force_full=force_full,
|
force_full=force_full,
|
||||||
)
|
)
|
||||||
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
if source_kind in {"ics", "webcal"}:
|
||||||
if source.source_kind in {"ics", "webcal"}:
|
|
||||||
return sync_ics_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
|
return sync_ics_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
|
||||||
if source.source_kind == "graph":
|
if source_kind == "graph":
|
||||||
return sync_graph_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, bearer_token=bearer_token, force_full=force_full)
|
return sync_graph_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, bearer_token=bearer_token, force_full=force_full)
|
||||||
if source.source_kind == "ews":
|
if source_kind == "ews":
|
||||||
return sync_ews_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
|
return sync_ews_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full)
|
||||||
raise CalendarError(f"Unsupported calendar sync source kind: {source.source_kind}")
|
raise CalendarError(f"Unsupported calendar sync source kind: {source_kind}")
|
||||||
|
|
||||||
|
|
||||||
def http_request(
|
def http_request(
|
||||||
@@ -2131,37 +2169,26 @@ def sync_caldav_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
force_full: bool = False,
|
force_full: bool = False,
|
||||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id)
|
session.commit()
|
||||||
# Serialize inbound application with local desired-state snapshots and
|
snapshot, client = _prepare_caldav_sync(
|
||||||
# outbound delivery for this source. The lock intentionally spans the
|
session,
|
||||||
# REPORT and its application so an older report cannot land after a newer
|
tenant_id=tenant_id,
|
||||||
# outbound write.
|
source_id=source_id,
|
||||||
source = (
|
client=client,
|
||||||
session.query(CalendarSyncSource)
|
password=password,
|
||||||
.filter(
|
bearer_token=bearer_token,
|
||||||
CalendarSyncSource.id == source.id,
|
|
||||||
CalendarSyncSource.tenant_id == tenant_id,
|
|
||||||
CalendarSyncSource.source_kind == "caldav",
|
|
||||||
CalendarSyncSource.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.populate_existing()
|
|
||||||
.with_for_update()
|
|
||||||
.one()
|
|
||||||
)
|
)
|
||||||
get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
|
||||||
client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token)
|
|
||||||
stats = CalendarCalDavSyncStats()
|
stats = CalendarCalDavSyncStats()
|
||||||
collection_props = CalDAVReportResult()
|
collection_props = CalDAVReportResult()
|
||||||
source.last_attempt_at = utcnow()
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
collection_props = client.propfind_collection()
|
collection_props = client.propfind_collection()
|
||||||
except CalDAVError as exc:
|
except CalDAVError as exc:
|
||||||
stats.errors.append(f"Collection PROPFIND failed; continuing with REPORT: {exc}")
|
stats.errors.append(f"Collection PROPFIND failed; continuing with REPORT: {exc}")
|
||||||
|
|
||||||
if source.sync_token and not force_full:
|
if snapshot.sync_token and not force_full:
|
||||||
try:
|
try:
|
||||||
report = client.sync_collection(source.sync_token)
|
report = client.sync_collection(snapshot.sync_token)
|
||||||
stats.used_sync_token = True
|
stats.used_sync_token = True
|
||||||
except CalDAVSyncUnsupported as exc:
|
except CalDAVSyncUnsupported as exc:
|
||||||
stats.errors.append(f"Sync token REPORT failed; falling back to full sync: {exc}")
|
stats.errors.append(f"Sync token REPORT failed; falling back to full sync: {exc}")
|
||||||
@@ -2171,7 +2198,16 @@ def sync_caldav_source(
|
|||||||
report = client.list_objects()
|
report = client.list_objects()
|
||||||
stats.full_sync = True
|
stats.full_sync = True
|
||||||
|
|
||||||
apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id)
|
report = _hydrate_caldav_report(client, report=report, stats=stats)
|
||||||
|
source = _lock_caldav_sync_source(session, snapshot)
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
|
apply_caldav_report(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
report=report,
|
||||||
|
stats=stats,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token
|
source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token
|
||||||
source.ctag = report.ctag or collection_props.ctag or source.ctag
|
source.ctag = report.ctag or collection_props.ctag or source.ctag
|
||||||
_finalize_sync_success(
|
_finalize_sync_success(
|
||||||
@@ -2183,10 +2219,262 @@ def sync_caldav_source(
|
|||||||
)
|
)
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_finalize_sync_error(session, source=source, error=exc)
|
session.rollback()
|
||||||
|
_record_caldav_sync_error(
|
||||||
|
session,
|
||||||
|
snapshot=snapshot,
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_caldav_sync(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_id: str,
|
||||||
|
client: CalDAVClient | None,
|
||||||
|
password: str | None,
|
||||||
|
bearer_token: str | None,
|
||||||
|
) -> tuple[_CalDAVSyncSnapshot, CalDAVClient]:
|
||||||
|
"""Resolve source configuration without retaining a database connection."""
|
||||||
|
|
||||||
|
with Session(bind=session.get_bind()) as preparation_session:
|
||||||
|
source = get_caldav_source(
|
||||||
|
preparation_session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
get_calendar(
|
||||||
|
preparation_session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar_id=source.calendar_id,
|
||||||
|
)
|
||||||
|
resolved_client = client or caldav_client_for_source(
|
||||||
|
preparation_session,
|
||||||
|
source,
|
||||||
|
password=password,
|
||||||
|
bearer_token=bearer_token,
|
||||||
|
)
|
||||||
|
snapshot = _CalDAVSyncSnapshot(
|
||||||
|
source_id=source.id,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
calendar_id=source.calendar_id,
|
||||||
|
collection_url=source.collection_url,
|
||||||
|
auth_type=source.auth_type,
|
||||||
|
username=source.username,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
sync_direction=source.sync_direction,
|
||||||
|
conflict_policy=source.conflict_policy,
|
||||||
|
sync_token=source.sync_token,
|
||||||
|
ctag=source.ctag,
|
||||||
|
)
|
||||||
|
return snapshot, resolved_client
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_caldav_sync_source(
|
||||||
|
session: Session,
|
||||||
|
snapshot: _CalDAVSyncSnapshot,
|
||||||
|
) -> CalendarSyncSource:
|
||||||
|
source = (
|
||||||
|
session.query(CalendarSyncSource)
|
||||||
|
.filter(
|
||||||
|
CalendarSyncSource.id == snapshot.source_id,
|
||||||
|
CalendarSyncSource.tenant_id == snapshot.tenant_id,
|
||||||
|
CalendarSyncSource.source_kind == "caldav",
|
||||||
|
CalendarSyncSource.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
current = (
|
||||||
|
source.calendar_id,
|
||||||
|
source.collection_url,
|
||||||
|
source.auth_type,
|
||||||
|
source.username,
|
||||||
|
source.credential_ref,
|
||||||
|
source.sync_direction,
|
||||||
|
source.conflict_policy,
|
||||||
|
source.sync_token,
|
||||||
|
source.ctag,
|
||||||
|
)
|
||||||
|
expected = (
|
||||||
|
snapshot.calendar_id,
|
||||||
|
snapshot.collection_url,
|
||||||
|
snapshot.auth_type,
|
||||||
|
snapshot.username,
|
||||||
|
snapshot.credential_ref,
|
||||||
|
snapshot.sync_direction,
|
||||||
|
snapshot.conflict_policy,
|
||||||
|
snapshot.sync_token,
|
||||||
|
snapshot.ctag,
|
||||||
|
)
|
||||||
|
if current != expected:
|
||||||
|
raise CalendarError(
|
||||||
|
"CalDAV source changed while the remote response was being fetched; retry the sync."
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _record_caldav_sync_error(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
snapshot: _CalDAVSyncSnapshot,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
source = _lock_caldav_sync_source(session, snapshot)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
|
_finalize_sync_error(session, source=source, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_caldav_report(
|
||||||
|
client: CalDAVClient,
|
||||||
|
*,
|
||||||
|
report: CalDAVReportResult,
|
||||||
|
stats: CalendarCalDavSyncStats,
|
||||||
|
) -> CalDAVReportResult:
|
||||||
|
objects: list[CalDAVObject] = []
|
||||||
|
for item in report.objects:
|
||||||
|
if item.deleted or item.calendar_data is not None:
|
||||||
|
objects.append(item)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
calendar_data = client.fetch_object(item.href)
|
||||||
|
stats.fetched += 1
|
||||||
|
objects.append(
|
||||||
|
CalDAVObject(
|
||||||
|
href=item.href,
|
||||||
|
etag=item.etag,
|
||||||
|
calendar_data=calendar_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except CalDAVNotFound:
|
||||||
|
objects.append(
|
||||||
|
CalDAVObject(
|
||||||
|
href=item.href,
|
||||||
|
etag=item.etag,
|
||||||
|
deleted=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stats.errors.append(
|
||||||
|
"CalDAV object disappeared during sync and was treated as deleted: "
|
||||||
|
f"{item.href}"
|
||||||
|
)
|
||||||
|
return CalDAVReportResult(
|
||||||
|
objects=objects,
|
||||||
|
sync_token=report.sync_token,
|
||||||
|
ctag=report.ctag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_remote_sync(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_id: str,
|
||||||
|
expected_kinds: set[str],
|
||||||
|
password: str | None = None,
|
||||||
|
bearer_token: str | None = None,
|
||||||
|
) -> tuple[_RemoteSyncSnapshot, dict[str, str]]:
|
||||||
|
with Session(bind=session.get_bind()) as preparation_session:
|
||||||
|
source = get_sync_source(
|
||||||
|
preparation_session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
if source.source_kind not in expected_kinds:
|
||||||
|
expected = "/".join(sorted(expected_kinds))
|
||||||
|
raise CalendarError(f"{expected} sync source not found")
|
||||||
|
get_calendar(
|
||||||
|
preparation_session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
calendar_id=source.calendar_id,
|
||||||
|
)
|
||||||
|
headers = source_auth_headers(
|
||||||
|
preparation_session,
|
||||||
|
source,
|
||||||
|
password=password,
|
||||||
|
bearer_token=bearer_token,
|
||||||
|
)
|
||||||
|
snapshot = _RemoteSyncSnapshot(
|
||||||
|
source_id=source.id,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
calendar_id=source.calendar_id,
|
||||||
|
source_kind=source.source_kind,
|
||||||
|
collection_url=source.collection_url,
|
||||||
|
auth_type=source.auth_type,
|
||||||
|
username=source.username,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
sync_token=source.sync_token,
|
||||||
|
ctag=source.ctag,
|
||||||
|
metadata=dict(source.metadata_ or {}),
|
||||||
|
)
|
||||||
|
return snapshot, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_remote_sync_source(
|
||||||
|
session: Session,
|
||||||
|
snapshot: _RemoteSyncSnapshot,
|
||||||
|
) -> CalendarSyncSource:
|
||||||
|
source = (
|
||||||
|
session.query(CalendarSyncSource)
|
||||||
|
.filter(
|
||||||
|
CalendarSyncSource.id == snapshot.source_id,
|
||||||
|
CalendarSyncSource.tenant_id == snapshot.tenant_id,
|
||||||
|
CalendarSyncSource.source_kind == snapshot.source_kind,
|
||||||
|
CalendarSyncSource.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
current = (
|
||||||
|
source.calendar_id,
|
||||||
|
source.collection_url,
|
||||||
|
source.auth_type,
|
||||||
|
source.username,
|
||||||
|
source.credential_ref,
|
||||||
|
source.sync_token,
|
||||||
|
source.ctag,
|
||||||
|
dict(source.metadata_ or {}),
|
||||||
|
)
|
||||||
|
expected = (
|
||||||
|
snapshot.calendar_id,
|
||||||
|
snapshot.collection_url,
|
||||||
|
snapshot.auth_type,
|
||||||
|
snapshot.username,
|
||||||
|
snapshot.credential_ref,
|
||||||
|
snapshot.sync_token,
|
||||||
|
snapshot.ctag,
|
||||||
|
snapshot.metadata,
|
||||||
|
)
|
||||||
|
if current != expected:
|
||||||
|
raise CalendarError(
|
||||||
|
f"{sync_source_label(snapshot.source_kind)} source changed while "
|
||||||
|
"the remote response was being fetched; retry the sync."
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _record_remote_sync_error(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
snapshot: _RemoteSyncSnapshot,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
source = _lock_remote_sync_source(session, snapshot)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
|
_finalize_sync_error(session, source=source, error=error)
|
||||||
|
|
||||||
|
|
||||||
def sync_ics_source(
|
def sync_ics_source(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -2197,20 +2485,30 @@ def sync_ics_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
force_full: bool = False,
|
force_full: bool = False,
|
||||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
session.commit()
|
||||||
if source.source_kind not in {"ics", "webcal"}:
|
snapshot, auth_headers = _prepare_remote_sync(
|
||||||
raise CalendarError("ICS/webcal sync source not found")
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
expected_kinds={"ics", "webcal"},
|
||||||
|
password=password,
|
||||||
|
bearer_token=bearer_token,
|
||||||
|
)
|
||||||
stats = CalendarCalDavSyncStats(full_sync=True)
|
stats = CalendarCalDavSyncStats(full_sync=True)
|
||||||
source.last_attempt_at = utcnow()
|
|
||||||
headers = {"Accept": "text/calendar, application/calendar+json;q=0.5, */*;q=0.1", "User-Agent": "govoplan-calendar-sync/0.1"}
|
headers = {"Accept": "text/calendar, application/calendar+json;q=0.5, */*;q=0.1", "User-Agent": "govoplan-calendar-sync/0.1"}
|
||||||
headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token))
|
headers.update(auth_headers)
|
||||||
if source.ctag and not force_full:
|
if snapshot.ctag and not force_full:
|
||||||
headers["If-None-Match"] = source.ctag
|
headers["If-None-Match"] = snapshot.ctag
|
||||||
last_modified = (source.metadata_ or {}).get("last_modified") if isinstance(source.metadata_, dict) else None
|
last_modified = snapshot.metadata.get("last_modified")
|
||||||
if isinstance(last_modified, str) and last_modified and not force_full:
|
if isinstance(last_modified, str) and last_modified and not force_full:
|
||||||
headers["If-Modified-Since"] = last_modified
|
headers["If-Modified-Since"] = last_modified
|
||||||
try:
|
try:
|
||||||
status_code, response_headers, body = http_request(source.collection_url, headers=headers)
|
status_code, response_headers, body = http_request(
|
||||||
|
snapshot.collection_url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
source = _lock_remote_sync_source(session, snapshot)
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
if status_code == 304:
|
if status_code == 304:
|
||||||
_finalize_sync_success(session, source=source, stats=stats)
|
_finalize_sync_success(session, source=source, stats=stats)
|
||||||
return source, stats
|
return source, stats
|
||||||
@@ -2230,7 +2528,9 @@ def sync_ics_source(
|
|||||||
_finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source)
|
_finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source)
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_finalize_sync_error(session, source=source, error=exc)
|
session.rollback()
|
||||||
|
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
|
||||||
|
session.commit()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -2329,58 +2629,90 @@ def sync_graph_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
force_full: bool = False,
|
force_full: bool = False,
|
||||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
session.commit()
|
||||||
if source.source_kind != "graph":
|
snapshot, auth_headers = _prepare_remote_sync(
|
||||||
raise CalendarError("Microsoft Graph sync source not found")
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
expected_kinds={"graph"},
|
||||||
|
bearer_token=bearer_token,
|
||||||
|
)
|
||||||
headers = {"Accept": "application/json", "User-Agent": "govoplan-calendar-graph/0.1", "Prefer": 'outlook.timezone="UTC"'}
|
headers = {"Accept": "application/json", "User-Agent": "govoplan-calendar-graph/0.1", "Prefer": 'outlook.timezone="UTC"'}
|
||||||
headers.update(source_auth_headers(session, source, bearer_token=bearer_token))
|
headers.update(auth_headers)
|
||||||
stats = CalendarCalDavSyncStats(full_sync=force_full or not bool(source.sync_token), used_sync_token=bool(source.sync_token and not force_full))
|
stats = CalendarCalDavSyncStats(
|
||||||
source.last_attempt_at = utcnow()
|
full_sync=force_full or not bool(snapshot.sync_token),
|
||||||
|
used_sync_token=bool(snapshot.sync_token and not force_full),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
next_url = source.sync_token if source.sync_token and not force_full else source.collection_url
|
next_url = (
|
||||||
seen_hrefs: set[str] = set()
|
snapshot.sync_token
|
||||||
|
if snapshot.sync_token and not force_full
|
||||||
|
else snapshot.collection_url
|
||||||
|
)
|
||||||
|
provider_items: list[dict[str, Any]] = []
|
||||||
delta_link: str | None = None
|
delta_link: str | None = None
|
||||||
for _page in range(50):
|
for _page in range(50):
|
||||||
next_url = same_origin_http_url(
|
next_url = same_origin_http_url(
|
||||||
source.collection_url,
|
snapshot.collection_url,
|
||||||
next_url,
|
next_url,
|
||||||
label="Microsoft Graph continuation URL",
|
label="Microsoft Graph continuation URL",
|
||||||
)
|
)
|
||||||
_status, _headers, body = http_request(
|
_status, _headers, body = http_request(
|
||||||
next_url,
|
next_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
credential_origin=source.collection_url,
|
credential_origin=snapshot.collection_url,
|
||||||
)
|
)
|
||||||
payload = json.loads(body or "{}")
|
payload = json.loads(body or "{}")
|
||||||
for item in payload.get("value", []):
|
for item in payload.get("value", []):
|
||||||
href = str(item.get("id") or "")
|
if isinstance(item, dict):
|
||||||
if not href:
|
provider_items.append(item)
|
||||||
continue
|
if len(provider_items) > REMOTE_SYNC_MAX_ITEMS:
|
||||||
if item.get("@removed"):
|
raise CalendarError(
|
||||||
stats.deleted += soft_delete_source_href(session, source=source, href=href)
|
"Microsoft Graph sync exceeded the configured item limit."
|
||||||
continue
|
)
|
||||||
seen_hrefs.add(href)
|
|
||||||
created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=graph_event_payload(item), user_id=user_id)
|
|
||||||
stats.created += created
|
|
||||||
stats.updated += updated
|
|
||||||
stats.unchanged += unchanged
|
|
||||||
next_link = payload.get("@odata.nextLink")
|
next_link = payload.get("@odata.nextLink")
|
||||||
raw_delta_link = payload.get("@odata.deltaLink")
|
raw_delta_link = payload.get("@odata.deltaLink")
|
||||||
if raw_delta_link:
|
if raw_delta_link:
|
||||||
delta_link = same_origin_http_url(
|
delta_link = same_origin_http_url(
|
||||||
source.collection_url,
|
snapshot.collection_url,
|
||||||
str(raw_delta_link),
|
str(raw_delta_link),
|
||||||
label="Microsoft Graph delta URL",
|
label="Microsoft Graph delta URL",
|
||||||
)
|
)
|
||||||
if not next_link:
|
if not next_link:
|
||||||
break
|
break
|
||||||
next_url = same_origin_http_url(
|
next_url = same_origin_http_url(
|
||||||
source.collection_url,
|
snapshot.collection_url,
|
||||||
str(next_link),
|
str(next_link),
|
||||||
label="Microsoft Graph continuation URL",
|
label="Microsoft Graph continuation URL",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise CalendarError("Microsoft Graph sync returned too many pages")
|
raise CalendarError("Microsoft Graph sync returned too many pages")
|
||||||
|
source = _lock_remote_sync_source(session, snapshot)
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
|
seen_hrefs: set[str] = set()
|
||||||
|
stats.fetched = len(provider_items)
|
||||||
|
for item in provider_items:
|
||||||
|
href = str(item.get("id") or "")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
if item.get("@removed"):
|
||||||
|
stats.deleted += soft_delete_source_href(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
href=href,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_hrefs.add(href)
|
||||||
|
created, updated, unchanged = import_provider_event(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
href=href,
|
||||||
|
provider_event=graph_event_payload(item),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
stats.created += created
|
||||||
|
stats.updated += updated
|
||||||
|
stats.unchanged += unchanged
|
||||||
if stats.full_sync:
|
if stats.full_sync:
|
||||||
stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs)
|
stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs)
|
||||||
source.sync_token = delta_link or source.sync_token
|
source.sync_token = delta_link or source.sync_token
|
||||||
@@ -2392,11 +2724,9 @@ def sync_graph_source(
|
|||||||
session.flush()
|
session.flush()
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
source.last_attempt_at = utcnow()
|
session.rollback()
|
||||||
source.last_status = "error"
|
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
|
||||||
source.last_error = str(exc)
|
session.commit()
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
session.flush()
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -2493,10 +2823,16 @@ def sync_ews_source(
|
|||||||
bearer_token: str | None = None,
|
bearer_token: str | None = None,
|
||||||
force_full: bool = False,
|
force_full: bool = False,
|
||||||
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
session.commit()
|
||||||
if source.source_kind != "ews":
|
snapshot, auth_headers = _prepare_remote_sync(
|
||||||
raise CalendarError("Exchange Web Services sync source not found")
|
session,
|
||||||
metadata = source.metadata_ or {}
|
tenant_id=tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
expected_kinds={"ews"},
|
||||||
|
password=password,
|
||||||
|
bearer_token=bearer_token,
|
||||||
|
)
|
||||||
|
metadata = snapshot.metadata
|
||||||
window_before = int(metadata.get("sync_window_days_before", 365)) if isinstance(metadata, dict) else 365
|
window_before = int(metadata.get("sync_window_days_before", 365)) if isinstance(metadata, dict) else 365
|
||||||
window_after = int(metadata.get("sync_window_days_after", 730)) if isinstance(metadata, dict) else 730
|
window_after = int(metadata.get("sync_window_days_after", 730)) if isinstance(metadata, dict) else 730
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
@@ -2507,12 +2843,26 @@ def sync_ews_source(
|
|||||||
"Content-Type": "text/xml; charset=utf-8",
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
"User-Agent": "govoplan-calendar-ews/0.1",
|
"User-Agent": "govoplan-calendar-ews/0.1",
|
||||||
}
|
}
|
||||||
headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token))
|
headers.update(auth_headers)
|
||||||
stats = CalendarCalDavSyncStats(full_sync=True)
|
stats = CalendarCalDavSyncStats(full_sync=True)
|
||||||
source.last_attempt_at = utcnow()
|
|
||||||
try:
|
try:
|
||||||
_status, _headers, body = http_request(source.collection_url, method="POST", headers=headers, body=ews_find_item_body(start=start, end=end, mailbox=metadata.get("mailbox") if isinstance(metadata, dict) else None))
|
_status, _headers, body = http_request(
|
||||||
|
snapshot.collection_url,
|
||||||
|
method="POST",
|
||||||
|
headers=headers,
|
||||||
|
body=ews_find_item_body(
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
mailbox=metadata.get("mailbox"),
|
||||||
|
),
|
||||||
|
)
|
||||||
items = parse_ews_calendar_items(body)
|
items = parse_ews_calendar_items(body)
|
||||||
|
if len(items) > REMOTE_SYNC_MAX_ITEMS:
|
||||||
|
raise CalendarError(
|
||||||
|
"Exchange Web Services sync exceeded the configured item limit."
|
||||||
|
)
|
||||||
|
source = _lock_remote_sync_source(session, snapshot)
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
seen_hrefs: set[str] = set()
|
seen_hrefs: set[str] = set()
|
||||||
for item in items:
|
for item in items:
|
||||||
href = item["href"]
|
href = item["href"]
|
||||||
@@ -2532,11 +2882,9 @@ def sync_ews_source(
|
|||||||
session.flush()
|
session.flush()
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
source.last_attempt_at = utcnow()
|
session.rollback()
|
||||||
source.last_status = "error"
|
_record_remote_sync_error(session, snapshot=snapshot, error=exc)
|
||||||
source.last_error = str(exc)
|
session.commit()
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
session.flush()
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -2909,7 +3257,23 @@ def _sync_due_sources(
|
|||||||
)
|
)
|
||||||
if tenant_id is not None:
|
if tenant_id is not None:
|
||||||
query = query.filter(CalendarSyncSource.tenant_id == tenant_id)
|
query = query.filter(CalendarSyncSource.tenant_id == tenant_id)
|
||||||
sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all()
|
sources = (
|
||||||
|
query.order_by(
|
||||||
|
CalendarSyncSource.next_sync_at.asc(),
|
||||||
|
CalendarSyncSource.created_at.asc(),
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(max(1, min(int(limit), 200)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# next_sync_at doubles as a recoverable lease. A worker that dies during
|
||||||
|
# remote I/O leaves the source eligible again after this timeout.
|
||||||
|
lease_until = due_at + timedelta(minutes=10)
|
||||||
|
for source in sources:
|
||||||
|
source.next_sync_at = lease_until
|
||||||
|
source.last_attempt_at = due_at
|
||||||
|
session.commit()
|
||||||
|
|
||||||
results: list[CalendarCalDavDueSyncResult] = []
|
results: list[CalendarCalDavDueSyncResult] = []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
previous_status = source.last_status
|
previous_status = source.last_status
|
||||||
@@ -2917,10 +3281,17 @@ def _sync_due_sources(
|
|||||||
refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory)
|
refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory)
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
|
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
|
||||||
_emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats)
|
_emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats)
|
||||||
|
session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
refreshed = get_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
source_id=source.id,
|
||||||
|
)
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
|
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
|
||||||
_emit_calendar_sync_notification(session, source=source, status="error", previous_status=previous_status, error=str(exc))
|
_emit_calendar_sync_notification(session, source=refreshed, status="error", previous_status=previous_status, error=str(exc))
|
||||||
session.flush()
|
session.commit()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -2949,7 +3320,7 @@ def apply_caldav_report(
|
|||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
source: CalendarSyncSource,
|
source: CalendarSyncSource,
|
||||||
client: CalDAVClient,
|
client: CalDAVClient | None = None,
|
||||||
report: CalDAVReportResult,
|
report: CalDAVReportResult,
|
||||||
stats: CalendarCalDavSyncStats,
|
stats: CalendarCalDavSyncStats,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
@@ -2971,13 +3342,9 @@ def apply_caldav_report(
|
|||||||
seen_hrefs.add(href)
|
seen_hrefs.add(href)
|
||||||
calendar_data = item.calendar_data
|
calendar_data = item.calendar_data
|
||||||
if calendar_data is None:
|
if calendar_data is None:
|
||||||
try:
|
raise CalendarError(
|
||||||
calendar_data = client.fetch_object(item.href)
|
"CalDAV reports must be hydrated before database application."
|
||||||
stats.fetched += 1
|
)
|
||||||
except CalDAVNotFound:
|
|
||||||
stats.deleted += soft_delete_caldav_href(session, source=source, href=href)
|
|
||||||
stats.errors.append(f"CalDAV object disappeared during sync and was treated as deleted: {href}")
|
|
||||||
continue
|
|
||||||
created, updated, unchanged, deleted = import_caldav_resource(
|
created, updated, unchanged, deleted = import_caldav_resource(
|
||||||
session,
|
session,
|
||||||
source=source,
|
source=source,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"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",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": ">=7.18.2 <8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
|
|||||||
110
webui/src/features/calendar/UpcomingEventsWidget.tsx
Normal file
110
webui/src/features/calendar/UpcomingEventsWidget.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { CalendarDays, MapPin } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
DashboardWidgetList,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingFrame,
|
||||||
|
useDashboardWidgetData,
|
||||||
|
type ApiSettings,
|
||||||
|
type DashboardWidgetConfiguration
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
listCalendarEvents,
|
||||||
|
type CalendarEvent
|
||||||
|
} from "../../api/calendar";
|
||||||
|
|
||||||
|
export default function UpcomingEventsWidget({
|
||||||
|
settings,
|
||||||
|
refreshKey,
|
||||||
|
configuration
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
refreshKey: number;
|
||||||
|
configuration: DashboardWidgetConfiguration;
|
||||||
|
}) {
|
||||||
|
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
|
||||||
|
const daysAhead = numberSetting(configuration.daysAhead, 14, 1, 90);
|
||||||
|
const showLocation = configuration.showLocation !== false;
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const start = new Date();
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + daysAhead);
|
||||||
|
const response = await listCalendarEvents(settings, {
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString()
|
||||||
|
});
|
||||||
|
return [...response.events]
|
||||||
|
.filter((event) => event.status !== "cancelled")
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, maxItems);
|
||||||
|
}, [daysAhead, maxItems, settings]);
|
||||||
|
const { data: events, loading, error } = useDashboardWidgetData(
|
||||||
|
load,
|
||||||
|
refreshKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingFrame loading={loading} label="Loading upcoming calendar events">
|
||||||
|
{error && (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText={`No events in the next ${daysAhead} days.`}
|
||||||
|
items={(events ?? []).map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
title: event.summary,
|
||||||
|
detail:
|
||||||
|
showLocation && event.location ? (
|
||||||
|
<>
|
||||||
|
<MapPin size={12} aria-hidden="true" /> {event.location}
|
||||||
|
</>
|
||||||
|
) : undefined,
|
||||||
|
meta: eventTimeLabel(event),
|
||||||
|
leading: <CalendarDays size={17} aria-hidden="true" />,
|
||||||
|
to: "/calendar"
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Link className="btn btn-secondary" to="/calendar">
|
||||||
|
Open calendar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimeLabel(event: CalendarEvent): string {
|
||||||
|
const start = new Date(event.start_at);
|
||||||
|
if (event.all_day) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short"
|
||||||
|
}).format(start);
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberSetting(
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number
|
||||||
|
): number {
|
||||||
|
const numeric = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(numeric)
|
||||||
|
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import type {
|
||||||
|
CalendarPickerUiCapability,
|
||||||
|
DashboardWidgetsUiCapability,
|
||||||
|
PlatformWebModule
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
import "./styles/calendar.css";
|
import "./styles/calendar.css";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import CalendarPicker from "./features/calendar/CalendarPicker";
|
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||||
|
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
||||||
|
|
||||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||||
|
|
||||||
@@ -13,6 +18,60 @@ const translations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||||
|
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
id: "calendar.upcoming",
|
||||||
|
surfaceId: "calendar.widget.upcoming",
|
||||||
|
title: "Upcoming events",
|
||||||
|
description: "The next events across visible calendars.",
|
||||||
|
moduleId: "calendar",
|
||||||
|
category: "Planning",
|
||||||
|
order: 40,
|
||||||
|
defaultVisible: false,
|
||||||
|
defaultSize: "medium",
|
||||||
|
supportedSizes: ["medium", "wide"],
|
||||||
|
anyOf: eventRead,
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfiguration: {
|
||||||
|
maxItems: 5,
|
||||||
|
daysAhead: 14,
|
||||||
|
showLocation: true
|
||||||
|
},
|
||||||
|
configurationFields: [
|
||||||
|
{
|
||||||
|
id: "maxItems",
|
||||||
|
label: "Maximum events",
|
||||||
|
kind: "number",
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
step: 1,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "daysAhead",
|
||||||
|
label: "Days ahead",
|
||||||
|
kind: "number",
|
||||||
|
min: 1,
|
||||||
|
max: 90,
|
||||||
|
step: 1,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "showLocation",
|
||||||
|
label: "Show locations",
|
||||||
|
kind: "boolean"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
render: ({ settings, refreshKey, configuration }) =>
|
||||||
|
createElement(UpcomingEventsWidget, {
|
||||||
|
settings,
|
||||||
|
refreshKey,
|
||||||
|
configuration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
export const calendarModule: PlatformWebModule = {
|
export const calendarModule: PlatformWebModule = {
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
@@ -21,11 +80,21 @@ export const calendarModule: PlatformWebModule = {
|
|||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "calendar.widget.upcoming",
|
||||||
|
moduleId: "calendar",
|
||||||
|
kind: "section",
|
||||||
|
label: "Upcoming events widget",
|
||||||
|
order: 40
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"calendar.picker": calendarPicker
|
"calendar.picker": calendarPicker,
|
||||||
|
"dashboard.widgets": calendarDashboardWidgets
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user