Compare commits
6 Commits
e6f7c45f0a
...
6e373dcdd6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e373dcdd6 | |||
| d1c033edc7 | |||
| b5cfba666c | |||
| 78b4afdec4 | |||
| 15596f0742 | |||
| a2320fcb5d |
@@ -153,16 +153,24 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
||||||
|
|
||||||
Worker command:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,default \
|
--queues send_email,append_sent,notifications,calendar,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||||
|
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||||
|
crashes, and expired worker leases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||||
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
@@ -275,7 +283,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,default`
|
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
||||||
- WebUI through the Vite dev server
|
- WebUI through the Vite dev server
|
||||||
- durable local files under `runtime/production-like/files`
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||||
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||||
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||||
|
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||||
|
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||||
|
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||||
|
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||||
|
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||||
@@ -27,10 +28,18 @@ celery.conf.update(
|
|||||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
|
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
task_reject_on_worker_lost=True,
|
task_reject_on_worker_lost=True,
|
||||||
|
beat_schedule={
|
||||||
|
"calendar-outbox-every-minute": {
|
||||||
|
"task": "govoplan.calendar.dispatch_outbox",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (None, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +76,16 @@ def _notification_dispatch() -> NotificationDispatchProvider:
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
if not isinstance(capability, CalendarOutboxProvider):
|
||||||
|
raise RuntimeError("Calendar outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||||
def send_email(self, job_id: str):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
@@ -111,3 +130,18 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
|
|||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
||||||
|
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
"""Drain durable Calendar operations; retry timing lives in the database."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _calendar_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
|
||||||
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|||||||
98
src/govoplan_core/core/calendar.py
Normal file
98
src/govoplan_core/core/calendar.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Calendar capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRequest:
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
status: str = "CONFIRMED"
|
||||||
|
transparency: str = "OPAQUE"
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
related_to: tuple[Mapping[str, object], ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRef:
|
||||||
|
id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarSchedulingProvider(Protocol):
|
||||||
|
def list_freebusy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
calendar_ids: Sequence[str] | None = None,
|
||||||
|
) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarOutboxProvider(Protocol):
|
||||||
|
"""Stable worker boundary for Calendar-owned external side effects."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, CalendarSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
@@ -5,7 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
@@ -274,7 +274,8 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
@@ -317,7 +318,8 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
|||||||
215
src/govoplan_core/core/poll.py
Normal file
215
src/govoplan_core/core/poll.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
|
||||||
|
|
||||||
|
|
||||||
|
class PollCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Poll capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRequest:
|
||||||
|
label: str
|
||||||
|
key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollCreateCommand:
|
||||||
|
title: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
description: str | None = None
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
workflow_state: str | None = None
|
||||||
|
workflow_steps: tuple[Mapping[str, object], ...] = ()
|
||||||
|
allow_anonymous: bool = False
|
||||||
|
allow_response_update: bool = True
|
||||||
|
min_choices: int = 1
|
||||||
|
max_choices: int | None = None
|
||||||
|
opens_at: datetime | None = None
|
||||||
|
closes_at: datetime | None = None
|
||||||
|
options: tuple[PollOptionRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollUpdateCommand:
|
||||||
|
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
allow_anonymous: bool
|
||||||
|
allow_response_update: bool
|
||||||
|
closes_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationCommand:
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRequest:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollSubmitResponseCommand:
|
||||||
|
respondent_id: str
|
||||||
|
respondent_label: str | None = None
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRef:
|
||||||
|
id: str
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollRef:
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
options: tuple[PollOptionRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRef:
|
||||||
|
id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRef:
|
||||||
|
invitation_id: str | None
|
||||||
|
submitted_at: datetime
|
||||||
|
# Optional for backwards compatibility with existing providers. A
|
||||||
|
# consumer can use it to reconcile an authenticated response without
|
||||||
|
# trusting client-supplied invitation metadata.
|
||||||
|
respondent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollSchedulingProvider(Protocol):
|
||||||
|
def create_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
command: PollCreateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollUpdateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str | None,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_workflow_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
workflow_state: str,
|
||||||
|
workflow_steps: Sequence[Mapping[str, object]],
|
||||||
|
context_module: str,
|
||||||
|
context_resource_type: str,
|
||||||
|
context_resource_id: str,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseSubmissionProvider(Protocol):
|
||||||
|
"""Optional extension for modules that collect responses through Poll."""
|
||||||
|
|
||||||
|
def submit_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollSubmitResponseCommand,
|
||||||
|
) -> PollResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, PollSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_submission_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseSubmissionProvider | None:
|
||||||
|
provider = poll_scheduling_provider(registry)
|
||||||
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
@@ -49,7 +49,12 @@ class Settings(BaseSettings):
|
|||||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||||
|
|
||||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||||
celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES")
|
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
||||||
|
calendar_outbox_terminal_retention_days: int = Field(
|
||||||
|
default=90,
|
||||||
|
ge=0,
|
||||||
|
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
|
)
|
||||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||||
|
|
||||||
# Development bootstrap only. Do not use this in production.
|
# Development bootstrap only. Do not use this in production.
|
||||||
|
|||||||
48
tests/test_calendar_outbox_worker.py
Normal file
48
tests/test_calendar_outbox_worker.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||||
|
def test_worker_commits_provider_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.dispatch_due.return_value = {
|
||||||
|
"processed": 1,
|
||||||
|
"succeeded": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"operations": [{"id": "operation-1", "status": "succeeded"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
|
||||||
|
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||||
|
):
|
||||||
|
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||||
|
|
||||||
|
provider.dispatch_due.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(result["succeeded"], 1)
|
||||||
|
|
||||||
|
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
|
||||||
|
{"queue": "calendar"},
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
|
||||||
|
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
|
||||||
|
self.assertEqual(schedule["schedule"], 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,11 +3,24 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
|
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
|
||||||
|
from govoplan_core.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
class InstallConfigTests(unittest.TestCase):
|
class InstallConfigTests(unittest.TestCase):
|
||||||
|
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
|
||||||
|
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
|
||||||
|
self.assertEqual(
|
||||||
|
Settings(
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
|
||||||
|
).calendar_outbox_terminal_retention_days,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||||
|
|
||||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||||
|
|
||||||
@@ -68,8 +81,10 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||||
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
|
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
|
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||||
|
|||||||
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
|
||||||
|
const normalized = source.replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
function assertIncludes(fragment, message) {
|
||||||
|
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
|
||||||
|
"drag enter/over must prevent the browser default and stop propagation"
|
||||||
|
);
|
||||||
|
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
|
||||||
|
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
|
||||||
|
assertIncludes(
|
||||||
|
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
|
||||||
|
"drag leave must prevent the browser default and stop propagation"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
|
||||||
|
"drop must prevent the browser default and stop propagation before resolving files"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
|
||||||
|
"disabled drops must route the disabled rejection reason"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
|
||||||
|
"drop must route the event DataTransfer through the shared resolver"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
|
||||||
|
"resolved files and empty drops must be routed through their explicit callbacks"
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
".catch(() => onRejectedDrop?.(\"unreadable\"))",
|
||||||
|
"resolver failures must route the unreadable rejection reason"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("FileDropZone event and result routing structure is intact.");
|
||||||
@@ -5,6 +5,7 @@ const packageByModule = {
|
|||||||
admin: "@govoplan/admin-webui",
|
admin: "@govoplan/admin-webui",
|
||||||
addresses: "@govoplan/addresses-webui",
|
addresses: "@govoplan/addresses-webui",
|
||||||
audit: "@govoplan/audit-webui",
|
audit: "@govoplan/audit-webui",
|
||||||
|
calendar: "@govoplan/calendar-webui",
|
||||||
campaigns: "@govoplan/campaign-webui",
|
campaigns: "@govoplan/campaign-webui",
|
||||||
dashboard: "@govoplan/dashboard-webui",
|
dashboard: "@govoplan/dashboard-webui",
|
||||||
docs: "@govoplan/docs-webui",
|
docs: "@govoplan/docs-webui",
|
||||||
@@ -25,6 +26,7 @@ const cases = [
|
|||||||
{ name: "access-with-admin", modules: ["access", "admin"] },
|
{ name: "access-with-admin", modules: ["access", "admin"] },
|
||||||
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
|
||||||
{ name: "dashboard-only", modules: ["dashboard"] },
|
{ name: "dashboard-only", modules: ["dashboard"] },
|
||||||
|
{ name: "calendar-only", modules: ["calendar"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
{ name: "organizations-only", modules: ["organizations"] },
|
{ name: "organizations-only", modules: ["organizations"] },
|
||||||
@@ -35,7 +37,7 @@ const cases = [
|
|||||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] }
|
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "calendar", "scheduling"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react";
|
import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react";
|
||||||
import { UploadCloud } from "lucide-react";
|
import { UploadCloud } from "lucide-react";
|
||||||
|
import { resolveDroppedFiles } from "./resolveDroppedFiles";
|
||||||
|
|
||||||
|
type RejectedDropReason = "disabled" | "empty" | "unreadable";
|
||||||
|
|
||||||
export type FileDropZoneProps = {
|
export type FileDropZoneProps = {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
@@ -14,6 +17,7 @@ export type FileDropZoneProps = {
|
|||||||
note?: ReactNode;
|
note?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
inputLabel?: string;
|
inputLabel?: string;
|
||||||
|
onRejectedDrop?: (reason: RejectedDropReason) => void;
|
||||||
onFiles: (files: File[]) => void | Promise<void>;
|
onFiles: (files: File[]) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ export default function FileDropZone({
|
|||||||
note,
|
note,
|
||||||
className = "",
|
className = "",
|
||||||
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608",
|
||||||
|
onRejectedDrop,
|
||||||
onFiles
|
onFiles
|
||||||
}: FileDropZoneProps) {
|
}: FileDropZoneProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -52,6 +57,13 @@ export default function FileDropZone({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
|
||||||
|
if (!interactionDisabled) setDragActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -70,15 +82,29 @@ export default function FileDropZone({
|
|||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragOver={(event) => {
|
onDragEnter={prepareFileDrag}
|
||||||
|
onDragOver={prepareFileDrag}
|
||||||
|
onDragLeave={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!interactionDisabled) setDragActive(true);
|
event.stopPropagation();
|
||||||
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||||
|
setDragActive(false);
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setDragActive(false)}
|
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
setDragActive(false);
|
setDragActive(false);
|
||||||
if (!interactionDisabled) void handleFiles(event.dataTransfer.files);
|
if (interactionDisabled) {
|
||||||
|
onRejectedDrop?.("disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void resolveDroppedFiles(event.dataTransfer).then((files) => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
onRejectedDrop?.("empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleFiles(files);
|
||||||
|
}).catch(() => onRejectedDrop?.("unreadable"));
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{showProgress ?
|
{showProgress ?
|
||||||
@@ -110,4 +136,4 @@ export default function FileDropZone({
|
|||||||
|
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
101
webui/src/components/resolveDroppedFiles.ts
Normal file
101
webui/src/components/resolveDroppedFiles.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
|
||||||
|
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
|
||||||
|
type FileSystemDirectoryHandleLike = {
|
||||||
|
kind: "directory";
|
||||||
|
values?: () => AsyncIterable<FileSystemHandleLike>;
|
||||||
|
};
|
||||||
|
type WebKitFileEntryLike = {
|
||||||
|
isFile: true;
|
||||||
|
isDirectory: false;
|
||||||
|
file: (
|
||||||
|
success: (file: File) => void,
|
||||||
|
error?: (error: DOMException) => void
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true };
|
||||||
|
type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike;
|
||||||
|
type DropItemWithFileHandles = DataTransferItem & {
|
||||||
|
getAsFileSystemHandle?: () => Promise<FileSystemHandleLike | null>;
|
||||||
|
webkitGetAsEntry?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a browser file drop exactly once, preferring the standard file list
|
||||||
|
* before progressively trying item and directory-drag compatibility APIs.
|
||||||
|
*/
|
||||||
|
export async function resolveDroppedFiles(
|
||||||
|
dataTransfer: Pick<DataTransfer, "files" | "items">
|
||||||
|
): Promise<File[]> {
|
||||||
|
const directFiles = Array.from(dataTransfer.files);
|
||||||
|
if (directFiles.length > 0) return directFiles;
|
||||||
|
|
||||||
|
const items = Array.from(dataTransfer.items).filter(
|
||||||
|
(item) => item.kind === "file"
|
||||||
|
) as DropItemWithFileHandles[];
|
||||||
|
const itemFiles = items
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
if (itemFiles.length > 0) return itemFiles;
|
||||||
|
|
||||||
|
const handleFiles = await filesFromDataTransferHandles(items);
|
||||||
|
if (handleFiles.length > 0) return handleFiles;
|
||||||
|
|
||||||
|
return filesFromWebKitEntries(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromDataTransferHandles(
|
||||||
|
items: DropItemWithFileHandles[]
|
||||||
|
): Promise<File[]> {
|
||||||
|
const files: File[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
let handle: FileSystemHandleLike | null | undefined;
|
||||||
|
try {
|
||||||
|
handle = await item.getAsFileSystemHandle?.();
|
||||||
|
} catch {
|
||||||
|
handle = null;
|
||||||
|
}
|
||||||
|
if (!handle) continue;
|
||||||
|
files.push(...await filesFromFileSystemHandle(handle));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromFileSystemHandle(
|
||||||
|
handle: FileSystemHandleLike
|
||||||
|
): Promise<File[]> {
|
||||||
|
if (handle.kind === "file") return [await handle.getFile()];
|
||||||
|
const values = handle.values?.();
|
||||||
|
if (!values) return [];
|
||||||
|
const files: File[] = [];
|
||||||
|
for await (const child of values) {
|
||||||
|
files.push(...await filesFromFileSystemHandle(child));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filesFromWebKitEntries(
|
||||||
|
items: DropItemWithFileHandles[]
|
||||||
|
): Promise<File[]> {
|
||||||
|
const entries = items.flatMap((item) => {
|
||||||
|
const entry = item.webkitGetAsEntry?.() as unknown;
|
||||||
|
return isWebKitEntryLike(entry) ? [entry] : [];
|
||||||
|
});
|
||||||
|
const files = await Promise.all(entries.map(fileFromWebKitEntry));
|
||||||
|
return files.filter((file): file is File => Boolean(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebKitEntryLike(value: unknown): value is WebKitEntryLike {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const entry = value as { isFile?: unknown; isDirectory?: unknown };
|
||||||
|
return (
|
||||||
|
typeof entry.isFile === "boolean" &&
|
||||||
|
typeof entry.isDirectory === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileFromWebKitEntry(entry: WebKitEntryLike): Promise<File | null> {
|
||||||
|
if (!entry.isFile) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -402,6 +402,22 @@ export type OrganizationFunctionPickerUiCapability = {
|
|||||||
renderLabel?: (context: OrganizationFunctionLabelContext) => ReactNode;
|
renderLabel?: (context: OrganizationFunctionLabelContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CalendarPickerProps = PlatformRouteContext & {
|
||||||
|
value: string;
|
||||||
|
onChange: (calendarId: string) => void;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: ReactNode;
|
||||||
|
emptyLabel?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarPickerUiCapability = {
|
||||||
|
CalendarPicker: ComponentType<CalendarPickerProps>;
|
||||||
|
};
|
||||||
|
|
||||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
|
||||||
|
|||||||
150
webui/tests/file-drop-resolver.test.ts
Normal file
150
webui/tests/file-drop-resolver.test.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles";
|
||||||
|
|
||||||
|
type FileDropItem = Omit<DataTransferItem, "getAsFileSystemHandle" | "webkitGetAsEntry"> & {
|
||||||
|
getAsFileSystemHandle?: () => Promise<unknown>;
|
||||||
|
webkitGetAsEntry?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFiles(actual: File[], expected: File[], message: string): void {
|
||||||
|
assert(actual.length === expected.length, `${message}: expected ${expected.length} file(s), got ${actual.length}`);
|
||||||
|
expected.forEach((file, index) => {
|
||||||
|
assert(actual[index] === file, `${message}: file ${index + 1} did not preserve object identity`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function transfer(files: File[] = [], items: FileDropItem[] = []): Pick<DataTransfer, "files" | "items"> {
|
||||||
|
return {
|
||||||
|
files: files as unknown as FileList,
|
||||||
|
items: items as unknown as DataTransferItemList
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(overrides: Partial<FileDropItem> = {}): FileDropItem {
|
||||||
|
return {
|
||||||
|
kind: "file",
|
||||||
|
type: "application/octet-stream",
|
||||||
|
getAsFile: () => null,
|
||||||
|
getAsString: () => undefined,
|
||||||
|
...overrides
|
||||||
|
} as FileDropItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function namedFile(name: string): File {
|
||||||
|
return new File([name], name, { type: "text/plain" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* handles(...entries: unknown[]): AsyncIterable<unknown> {
|
||||||
|
for (const entry of entries) yield entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
const direct = namedFile("direct.txt");
|
||||||
|
let directItemCalls = 0;
|
||||||
|
const directResult = await resolveDroppedFiles(transfer([direct], [item({
|
||||||
|
getAsFile: () => {
|
||||||
|
directItemCalls += 1;
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
})]));
|
||||||
|
assertFiles(directResult, [direct], "native DataTransfer.files takes precedence");
|
||||||
|
assert(directItemCalls === 0, "native files must not also be resolved from items");
|
||||||
|
|
||||||
|
const itemA = namedFile("item-a.txt");
|
||||||
|
const itemB = namedFile("item-b.txt");
|
||||||
|
let itemHandleCalls = 0;
|
||||||
|
const itemResult = await resolveDroppedFiles(transfer([], [
|
||||||
|
item({
|
||||||
|
getAsFile: () => itemA,
|
||||||
|
getAsFileSystemHandle: async () => {
|
||||||
|
itemHandleCalls += 1;
|
||||||
|
return { kind: "file", getFile: async () => itemA };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
item({ getAsFile: () => itemB }),
|
||||||
|
item({ kind: "string", getAsFile: () => null })
|
||||||
|
]));
|
||||||
|
assertFiles(itemResult, [itemA, itemB], "DataTransferItem.getAsFile fallback resolves file items");
|
||||||
|
assert(itemHandleCalls === 0, "item files must not also be resolved from file-system handles");
|
||||||
|
|
||||||
|
const topLevelFile = namedFile("top-level.txt");
|
||||||
|
const rootFile = namedFile("root.txt");
|
||||||
|
const nestedFile = namedFile("nested.txt");
|
||||||
|
const deepFile = namedFile("deep.txt");
|
||||||
|
const deepDirectory = {
|
||||||
|
kind: "directory",
|
||||||
|
values: () => handles({ kind: "file", getFile: async () => deepFile })
|
||||||
|
};
|
||||||
|
const nestedDirectory = {
|
||||||
|
kind: "directory",
|
||||||
|
values: () => handles(
|
||||||
|
{ kind: "file", getFile: async () => nestedFile },
|
||||||
|
deepDirectory
|
||||||
|
)
|
||||||
|
};
|
||||||
|
const rootDirectory = {
|
||||||
|
kind: "directory",
|
||||||
|
values: () => handles(
|
||||||
|
{ kind: "file", getFile: async () => rootFile },
|
||||||
|
nestedDirectory
|
||||||
|
)
|
||||||
|
};
|
||||||
|
const handleResult = await resolveDroppedFiles(transfer([], [
|
||||||
|
item({
|
||||||
|
getAsFileSystemHandle: async () => ({
|
||||||
|
kind: "file",
|
||||||
|
getFile: async () => topLevelFile
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
item({ getAsFileSystemHandle: async () => rootDirectory })
|
||||||
|
]));
|
||||||
|
assertFiles(
|
||||||
|
handleResult,
|
||||||
|
[topLevelFile, rootFile, nestedFile, deepFile],
|
||||||
|
"File System Access file handles resolve and directory handles recurse in enumeration order"
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacy = namedFile("legacy.txt");
|
||||||
|
const legacyResult = await resolveDroppedFiles(transfer([], [item({
|
||||||
|
getAsFileSystemHandle: async () => {
|
||||||
|
throw new Error("file-system handles unavailable");
|
||||||
|
},
|
||||||
|
webkitGetAsEntry: () => ({
|
||||||
|
isFile: true,
|
||||||
|
isDirectory: false,
|
||||||
|
file: (resolve: (file: File) => void) => resolve(legacy)
|
||||||
|
})
|
||||||
|
})]));
|
||||||
|
assertFiles(legacyResult, [legacy], "legacy webkit file entry remains a final fallback");
|
||||||
|
|
||||||
|
const emptyResult = await resolveDroppedFiles(transfer([], [
|
||||||
|
item({ kind: "string" }),
|
||||||
|
item({
|
||||||
|
getAsFileSystemHandle: async () => ({ kind: "directory" }),
|
||||||
|
webkitGetAsEntry: () => ({ isFile: false, isDirectory: true })
|
||||||
|
})
|
||||||
|
]));
|
||||||
|
assertFiles(emptyResult, [], "empty and non-readable directory paths resolve to an empty drop");
|
||||||
|
|
||||||
|
let unreadableRejected = false;
|
||||||
|
try {
|
||||||
|
await resolveDroppedFiles(transfer([], [item({
|
||||||
|
getAsFileSystemHandle: async () => ({
|
||||||
|
kind: "file",
|
||||||
|
getFile: async () => {
|
||||||
|
throw new Error("permission denied");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})]));
|
||||||
|
} catch (error) {
|
||||||
|
unreadableRejected = error instanceof Error && error.message === "permission denied";
|
||||||
|
}
|
||||||
|
assert(unreadableRejected, "an unreadable file handle rejects so the component can report the unreadable reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
void run().catch((error) => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
20
webui/tsconfig.file-drop-tests.json
Normal file
20
webui/tsconfig.file-drop-tests.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": ".file-drop-test-build",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"tests/file-drop-resolver.test.ts",
|
||||||
|
"src/components/resolveDroppedFiles.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user