Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c5519908a | ||
|
|
dc56687af6 | ||
|
|
2c70c553ac | ||
|
|
8627d0e135 | ||
|
|
d2adcca7ae | ||
|
|
002ca4b371 | ||
|
|
641bead0d8 | ||
|
|
3c305753d6 | ||
|
|
ef513816f8 | ||
|
|
b7653b58f4 | ||
|
|
ce92499333 |
@@ -33,6 +33,14 @@ Files and mail are optional module integrations declared in the campaign manifes
|
|||||||
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
|
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
|
||||||
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
|
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
|
||||||
|
|
||||||
|
Public campaign, version, job, and report responses expose business data and
|
||||||
|
delivery evidence, but never process-local paths, storage-backend keys, or
|
||||||
|
worker claim tokens. Operational troubleshooting uses the dedicated job
|
||||||
|
diagnostics endpoint and requires the tenant-level
|
||||||
|
`campaigns:diagnostic:read` permission. The campaign sender role receives this
|
||||||
|
permission; tenant-wide administrator scopes continue to grant it through the
|
||||||
|
standard policy evaluator.
|
||||||
|
|
||||||
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
|
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
|
||||||
|
|
||||||
Campaign also provides narrow kernel capabilities so other modules and core
|
Campaign also provides narrow kernel capabilities so other modules and core
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -18,6 +17,7 @@ from govoplan_campaign.backend.path_security import (
|
|||||||
assert_logical_relative_path,
|
assert_logical_relative_path,
|
||||||
is_managed_source,
|
is_managed_source,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.template_rendering import render_template
|
||||||
|
|
||||||
|
|
||||||
class AttachmentScope(StrEnum):
|
class AttachmentScope(StrEnum):
|
||||||
@@ -145,43 +145,8 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
|
|||||||
return (campaign_path.parent / path).resolve()
|
return (campaign_path.parent / path).resolve()
|
||||||
|
|
||||||
|
|
||||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
|
||||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_template_key(raw: str) -> str:
|
|
||||||
key = raw.strip()
|
|
||||||
if key.startswith("fields."):
|
|
||||||
key = key.removeprefix("fields.")
|
|
||||||
elif key.startswith("local."):
|
|
||||||
key = "local::" + key.removeprefix("local.")
|
|
||||||
elif key.startswith("global."):
|
|
||||||
key = "global::" + key.removeprefix("global.")
|
|
||||||
|
|
||||||
if key.startswith("local::") or key.startswith("global::"):
|
|
||||||
return key
|
|
||||||
if key.startswith("local:"):
|
|
||||||
return "local::" + key.removeprefix("local:")
|
|
||||||
if key.startswith("global:"):
|
|
||||||
return "global::" + key.removeprefix("global:")
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def _render_template(template: str, values: dict[str, Any]) -> str:
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
|
||||||
key = _normalize_template_key(match.group(1))
|
|
||||||
if key in values:
|
|
||||||
value = values[key]
|
|
||||||
return "" if value is None else str(value)
|
|
||||||
return match.group(0)
|
|
||||||
|
|
||||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
|
||||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
|
||||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
|
||||||
|
|
||||||
|
|
||||||
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
|
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
|
||||||
rendered = _render_template(config.base_dir, values).strip()
|
rendered = render_template(config.base_dir, values, keep_missing=True).strip()
|
||||||
return rendered or "."
|
return rendered or "."
|
||||||
|
|
||||||
|
|
||||||
@@ -265,7 +230,7 @@ def _attachment_zip_archive(
|
|||||||
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
||||||
if archive is None:
|
if archive is None:
|
||||||
return None
|
return None
|
||||||
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip"
|
rendered = render_template(archive.name or "attachments.zip", values, keep_missing=True).strip() or "attachments.zip"
|
||||||
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
|
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
|
||||||
|
|
||||||
|
|
||||||
@@ -444,7 +409,7 @@ def _resolve_one_config(
|
|||||||
match_index: AttachmentMatchIndex | None = None,
|
match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> ResolvedAttachment:
|
) -> ResolvedAttachment:
|
||||||
rendered_base_dir = _rendered_base_dir(config, values)
|
rendered_base_dir = _rendered_base_dir(config, values)
|
||||||
rendered_file_filter = _render_template(config.file_filter, values)
|
rendered_file_filter = render_template(config.file_filter, values, keep_missing=True)
|
||||||
directory, selected_base_path = _resolve_attachment_directory(
|
directory, selected_base_path = _resolve_attachment_directory(
|
||||||
campaign_file=campaign_file,
|
campaign_file=campaign_file,
|
||||||
campaign_config=campaign_config,
|
campaign_config=campaign_config,
|
||||||
|
|||||||
@@ -299,6 +299,237 @@ def _mock_sent_folder(config: Any) -> str:
|
|||||||
return "Sent"
|
return "Sent"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_campaign_version(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None,
|
||||||
|
) -> tuple[Campaign, CampaignVersion]:
|
||||||
|
campaign = (
|
||||||
|
session.query(Campaign)
|
||||||
|
.filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if not campaign:
|
||||||
|
raise MockCampaignSendError("Campaign not found or not accessible")
|
||||||
|
wanted_version_id = version_id or campaign.current_version_id
|
||||||
|
if not wanted_version_id:
|
||||||
|
raise MockCampaignSendError("Campaign has no current version")
|
||||||
|
version = session.get(CampaignVersion, wanted_version_id)
|
||||||
|
if not version or version.campaign_id != campaign.id:
|
||||||
|
raise MockCampaignSendError(
|
||||||
|
"Campaign version not found or not part of campaign"
|
||||||
|
)
|
||||||
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None:
|
||||||
|
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
|
||||||
|
if clear_mailbox and mailbox is not None:
|
||||||
|
mailbox.clear_records()
|
||||||
|
return mailbox
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mock_campaign_run(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
mailbox: Any | None,
|
||||||
|
send: bool,
|
||||||
|
include_warnings: bool,
|
||||||
|
include_needs_review: bool,
|
||||||
|
append_sent: bool,
|
||||||
|
check_files: bool,
|
||||||
|
) -> tuple[Any, Any, _MockSendBatch]:
|
||||||
|
files = files_integration()
|
||||||
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
|
assert_server_safe_campaign_paths(
|
||||||
|
raw_json,
|
||||||
|
managed_files_available=files.available,
|
||||||
|
)
|
||||||
|
with files.prepared_campaign_snapshot(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
raw_json=raw_json,
|
||||||
|
include_bytes=True,
|
||||||
|
prefix="govoplan-mock-send-",
|
||||||
|
) as prepared:
|
||||||
|
prepared_raw = load_campaign_json(prepared.path)
|
||||||
|
config = load_campaign_config_from_json(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
raw_json=prepared_raw,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
)
|
||||||
|
validation_report = validate_campaign_config(
|
||||||
|
config,
|
||||||
|
campaign_file=prepared.path,
|
||||||
|
check_files=check_files,
|
||||||
|
)
|
||||||
|
build_result = build_campaign_messages(
|
||||||
|
config,
|
||||||
|
campaign_file=prepared.path,
|
||||||
|
write_eml=False,
|
||||||
|
)
|
||||||
|
files.annotate_built_messages_with_managed_files(
|
||||||
|
build_result.built_messages,
|
||||||
|
prepared.managed_files_by_local_path,
|
||||||
|
)
|
||||||
|
send_batch = _mock_send_batch(
|
||||||
|
config=config,
|
||||||
|
built_messages=build_result.built_messages,
|
||||||
|
mailbox=mailbox,
|
||||||
|
send=send,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
|
append_sent=append_sent,
|
||||||
|
)
|
||||||
|
return validation_report, build_result, send_batch
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
||||||
|
payload = validation_report.model_dump(mode="json")
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"ok": validation_report.ok,
|
||||||
|
"error_count": validation_report.error_count,
|
||||||
|
"warning_count": validation_report.warning_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_build_payload(build_result: Any) -> dict[str, Any]:
|
||||||
|
report = build_result.report
|
||||||
|
payload = report.model_dump(mode="json")
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"built_count": report.built_count,
|
||||||
|
"queueable_count": report.queueable_count,
|
||||||
|
"needs_review_count": report.needs_review_count,
|
||||||
|
"blocked_count": report.blocked_count,
|
||||||
|
"warning_count": report.warning_count,
|
||||||
|
"ready_count": report.ready_count,
|
||||||
|
"messages": [_message_payload(message) for message in report.messages],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_send_steps(
|
||||||
|
*,
|
||||||
|
validation_report: Any,
|
||||||
|
validation_payload: dict[str, Any],
|
||||||
|
build_report: Any,
|
||||||
|
send_batch: _MockSendBatch,
|
||||||
|
send: bool,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "validate",
|
||||||
|
"label": "Validate campaign JSON",
|
||||||
|
"status": "ok" if validation_report.ok else "needs_review",
|
||||||
|
"summary": validation_payload,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "build",
|
||||||
|
"label": "Build messages",
|
||||||
|
"status": "ok" if build_report.queueable_count else "needs_review",
|
||||||
|
"summary": {
|
||||||
|
"built": build_report.built_count,
|
||||||
|
"queueable": build_report.queueable_count,
|
||||||
|
"needs_review": build_report.needs_review_count,
|
||||||
|
"blocked": build_report.blocked_count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "send",
|
||||||
|
"label": "Mock SMTP delivery",
|
||||||
|
"status": (
|
||||||
|
"skipped"
|
||||||
|
if not send
|
||||||
|
else "ok"
|
||||||
|
if send_batch.failed_count == 0
|
||||||
|
else "needs_review"
|
||||||
|
),
|
||||||
|
"summary": {
|
||||||
|
"attempted": send_batch.attempted_count,
|
||||||
|
"sent": send_batch.sent_count,
|
||||||
|
"failed": send_batch.failed_count,
|
||||||
|
"skipped": send_batch.skipped_count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "imap",
|
||||||
|
"label": "Mock IMAP Sent append",
|
||||||
|
"status": (
|
||||||
|
"skipped"
|
||||||
|
if not send or not append_sent
|
||||||
|
else "ok"
|
||||||
|
if send_batch.imap_failed_count == 0
|
||||||
|
else "needs_review"
|
||||||
|
),
|
||||||
|
"summary": {
|
||||||
|
"appended": send_batch.imap_appended_count,
|
||||||
|
"failed": send_batch.imap_failed_count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_campaign_send_response(
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
mailbox: Any | None,
|
||||||
|
validation_report: Any,
|
||||||
|
validation_payload: dict[str, Any],
|
||||||
|
build_result: Any,
|
||||||
|
build_payload: dict[str, Any],
|
||||||
|
send_batch: _MockSendBatch,
|
||||||
|
send: bool,
|
||||||
|
include_warnings: bool,
|
||||||
|
include_needs_review: bool,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": version.id,
|
||||||
|
"version_number": version.version_number,
|
||||||
|
"send_requested": send,
|
||||||
|
"include_warnings": include_warnings,
|
||||||
|
"include_needs_review": include_needs_review,
|
||||||
|
"append_sent": append_sent,
|
||||||
|
"steps": _mock_send_steps(
|
||||||
|
validation_report=validation_report,
|
||||||
|
validation_payload=validation_payload,
|
||||||
|
build_report=build_result.report,
|
||||||
|
send_batch=send_batch,
|
||||||
|
send=send,
|
||||||
|
append_sent=append_sent,
|
||||||
|
),
|
||||||
|
"validation": validation_payload,
|
||||||
|
"build": build_payload,
|
||||||
|
"send": {
|
||||||
|
"attempted_count": send_batch.attempted_count,
|
||||||
|
"sent_count": send_batch.sent_count,
|
||||||
|
"failed_count": send_batch.failed_count,
|
||||||
|
"skipped_count": send_batch.skipped_count,
|
||||||
|
"imap_appended_count": send_batch.imap_appended_count,
|
||||||
|
"imap_failed_count": send_batch.imap_failed_count,
|
||||||
|
"results": send_batch.results,
|
||||||
|
},
|
||||||
|
"mailbox": {
|
||||||
|
"messages": mailbox.list_records(limit=200) if mailbox is not None else []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_mock_campaign_send(
|
def run_mock_campaign_send(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -320,87 +551,38 @@ def run_mock_campaign_send(
|
|||||||
mailbox only when send=True.
|
mailbox only when send=True.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
|
campaign, version = _mock_campaign_version(
|
||||||
if not campaign:
|
|
||||||
raise MockCampaignSendError("Campaign not found or not accessible")
|
|
||||||
wanted_version_id = version_id or campaign.current_version_id
|
|
||||||
if not wanted_version_id:
|
|
||||||
raise MockCampaignSendError("Campaign has no current version")
|
|
||||||
version = session.get(CampaignVersion, wanted_version_id)
|
|
||||||
if not version or version.campaign_id != campaign.id:
|
|
||||||
raise MockCampaignSendError("Campaign version not found or not part of campaign")
|
|
||||||
|
|
||||||
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
|
|
||||||
if clear_mailbox and mailbox is not None:
|
|
||||||
mailbox.clear_records()
|
|
||||||
|
|
||||||
files = files_integration()
|
|
||||||
assert_server_safe_campaign_paths(
|
|
||||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
|
||||||
managed_files_available=files.available,
|
|
||||||
)
|
|
||||||
with files.prepared_campaign_snapshot(
|
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign_id,
|
||||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
version_id=version_id,
|
||||||
include_bytes=True,
|
)
|
||||||
prefix="govoplan-mock-send-",
|
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
|
||||||
) as prepared:
|
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||||
prepared_raw = load_campaign_json(prepared.path)
|
session,
|
||||||
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
tenant_id=tenant_id,
|
||||||
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
|
campaign=campaign,
|
||||||
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
version=version,
|
||||||
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
|
||||||
|
|
||||||
send_batch = _mock_send_batch(
|
|
||||||
config=config,
|
|
||||||
built_messages=build_result.built_messages,
|
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
send=send,
|
send=send,
|
||||||
include_warnings=include_warnings,
|
include_warnings=include_warnings,
|
||||||
include_needs_review=include_needs_review,
|
include_needs_review=include_needs_review,
|
||||||
append_sent=append_sent,
|
append_sent=append_sent,
|
||||||
|
check_files=check_files,
|
||||||
|
)
|
||||||
|
validation_payload = _mock_validation_payload(validation_report)
|
||||||
|
build_payload = _mock_build_payload(build_result)
|
||||||
|
return _mock_campaign_send_response(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
mailbox=mailbox,
|
||||||
|
validation_report=validation_report,
|
||||||
|
validation_payload=validation_payload,
|
||||||
|
build_result=build_result,
|
||||||
|
build_payload=build_payload,
|
||||||
|
send_batch=send_batch,
|
||||||
|
send=send,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
|
append_sent=append_sent,
|
||||||
)
|
)
|
||||||
|
|
||||||
validation_json = validation_report.model_dump(mode="json")
|
|
||||||
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
|
|
||||||
build_report = build_result.report
|
|
||||||
build_json = build_report.model_dump(mode="json")
|
|
||||||
build_json.update({
|
|
||||||
"built_count": build_report.built_count,
|
|
||||||
"queueable_count": build_report.queueable_count,
|
|
||||||
"needs_review_count": build_report.needs_review_count,
|
|
||||||
"blocked_count": build_report.blocked_count,
|
|
||||||
"warning_count": build_report.warning_count,
|
|
||||||
"ready_count": build_report.ready_count,
|
|
||||||
"messages": [_message_payload(message) for message in build_report.messages],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"campaign_id": campaign.id,
|
|
||||||
"version_id": version.id,
|
|
||||||
"version_number": version.version_number,
|
|
||||||
"send_requested": send,
|
|
||||||
"include_warnings": include_warnings,
|
|
||||||
"include_needs_review": include_needs_review,
|
|
||||||
"append_sent": append_sent,
|
|
||||||
"steps": [
|
|
||||||
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
|
|
||||||
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
|
|
||||||
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
|
|
||||||
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}},
|
|
||||||
],
|
|
||||||
"validation": validation_json,
|
|
||||||
"build": build_json,
|
|
||||||
"send": {
|
|
||||||
"attempted_count": send_batch.attempted_count,
|
|
||||||
"sent_count": send_batch.sent_count,
|
|
||||||
"failed_count": send_batch.failed_count,
|
|
||||||
"skipped_count": send_batch.skipped_count,
|
|
||||||
"imap_appended_count": send_batch.imap_appended_count,
|
|
||||||
"imap_failed_count": send_batch.imap_failed_count,
|
|
||||||
"results": send_batch.results,
|
|
||||||
},
|
|
||||||
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ PERMISSIONS = (
|
|||||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
||||||
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
|
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
|
||||||
|
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
|
||||||
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
||||||
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
||||||
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
|
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
|
||||||
@@ -111,6 +112,7 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:send",
|
"campaigns:campaign:send",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
|
"campaigns:diagnostic:read",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
"campaigns:report:send",
|
"campaigns:report:send",
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ from govoplan_campaign.backend.campaign.models import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||||
|
from govoplan_campaign.backend.template_rendering import (
|
||||||
|
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||||
|
render_template as _render_template,
|
||||||
|
)
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
CampaignBuildReport,
|
CampaignBuildReport,
|
||||||
@@ -47,28 +51,6 @@ from .models import (
|
|||||||
MessageValidationStatus,
|
MessageValidationStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
|
||||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_template_key(raw: str) -> str:
|
|
||||||
key = raw.strip()
|
|
||||||
if key.startswith("fields."):
|
|
||||||
key = key.removeprefix("fields.")
|
|
||||||
elif key.startswith("local."):
|
|
||||||
key = "local::" + key.removeprefix("local.")
|
|
||||||
elif key.startswith("global."):
|
|
||||||
key = "global::" + key.removeprefix("global.")
|
|
||||||
|
|
||||||
if key.startswith("local::") or key.startswith("global::"):
|
|
||||||
return key
|
|
||||||
if key.startswith("local:"):
|
|
||||||
return "local::" + key.removeprefix("local:")
|
|
||||||
if key.startswith("global:"):
|
|
||||||
return "global::" + key.removeprefix("global:")
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class BuiltMessage:
|
class BuiltMessage:
|
||||||
draft: MessageDraft
|
draft: MessageDraft
|
||||||
@@ -123,29 +105,6 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
|
|||||||
return path.read_text(encoding=encoding)
|
return path.read_text(encoding=encoding)
|
||||||
|
|
||||||
|
|
||||||
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
|
||||||
def replace(match: re.Match[str]) -> str:
|
|
||||||
key = _normalize_template_key(match.group(1))
|
|
||||||
if key in values:
|
|
||||||
value = values[key]
|
|
||||||
return "" if value is None else str(value)
|
|
||||||
return match.group(0) if keep_missing else ""
|
|
||||||
|
|
||||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
|
||||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
|
||||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
|
||||||
if not text:
|
|
||||||
return set()
|
|
||||||
return {
|
|
||||||
_normalize_template_key(match.group(1))
|
|
||||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
|
||||||
for match in pattern.finditer(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
||||||
if recipient is None:
|
if recipient is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import copy
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -44,8 +45,9 @@ class CampaignPersistenceError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_dirs() -> None:
|
def _ensure_dirs() -> None:
|
||||||
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
|
||||||
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||||
|
directory.chmod(0o700)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +76,18 @@ def load_campaign_config_from_json(
|
|||||||
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
|
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
|
||||||
_ensure_dirs()
|
_ensure_dirs()
|
||||||
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
|
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
|
||||||
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(path, flags, 0o600)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||||
|
descriptor = -1
|
||||||
|
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
||||||
|
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
|
||||||
|
|
||||||
|
|
||||||
class CampaignReportError(RuntimeError):
|
class CampaignReportError(RuntimeError):
|
||||||
@@ -62,10 +63,10 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
|
|||||||
"id": version.id,
|
"id": version.id,
|
||||||
"version_number": version.version_number,
|
"version_number": version.version_number,
|
||||||
"schema_version": version.schema_version,
|
"schema_version": version.schema_version,
|
||||||
"source_filename": version.source_filename,
|
"source_filename": public_source_filename(version.source_filename),
|
||||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||||
"validation_summary": version.validation_summary,
|
"validation_summary": public_campaign_payload(version.validation_summary),
|
||||||
"build_summary": version.build_summary,
|
"build_summary": public_campaign_payload(version.build_summary),
|
||||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||||
}
|
}
|
||||||
@@ -323,8 +324,6 @@ def _job_evidence_row(
|
|||||||
"campaign_id": job.campaign_id,
|
"campaign_id": job.campaign_id,
|
||||||
"campaign_version_id": job.campaign_version_id,
|
"campaign_version_id": job.campaign_version_id,
|
||||||
"message_id_header": job.message_id_header,
|
"message_id_header": job.message_id_header,
|
||||||
"eml_storage_key": job.eml_storage_key,
|
|
||||||
"eml_local_path": job.eml_local_path,
|
|
||||||
"from": _address_summary(recipients.get("from")),
|
"from": _address_summary(recipients.get("from")),
|
||||||
"to": _address_summary(recipients.get("to")),
|
"to": _address_summary(recipients.get("to")),
|
||||||
"cc": _address_summary(recipients.get("cc")),
|
"cc": _address_summary(recipients.get("cc")),
|
||||||
@@ -603,8 +602,6 @@ def generate_jobs_csv(
|
|||||||
"last_error",
|
"last_error",
|
||||||
"eml_size_bytes",
|
"eml_size_bytes",
|
||||||
"eml_sha256",
|
"eml_sha256",
|
||||||
"eml_storage_key",
|
|
||||||
"eml_local_path",
|
|
||||||
"issues_count",
|
"issues_count",
|
||||||
"attachment_config_count",
|
"attachment_config_count",
|
||||||
"matched_file_count",
|
"matched_file_count",
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path, PureWindowsPath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# These fields locate process-local or storage-backend resources, or authorize
|
||||||
|
# a worker claim. They are useful for tightly controlled diagnostics but are
|
||||||
|
# not part of the campaign business-data contract.
|
||||||
|
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"campaign_file",
|
||||||
|
"claim_token",
|
||||||
|
"eml_local_path",
|
||||||
|
"eml_path",
|
||||||
|
"eml_storage_key",
|
||||||
|
"local_path",
|
||||||
|
"source_base_path",
|
||||||
|
"storage_bucket",
|
||||||
|
"storage_key",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def public_campaign_payload(value: Any) -> Any:
|
||||||
|
"""Return a detached payload without infrastructure-only locators."""
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: public_campaign_payload(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [public_campaign_payload(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(public_campaign_payload(item) for item in value)
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
||||||
|
def public_campaign_configuration(value: Any) -> Any:
|
||||||
|
"""Return campaign JSON without infrastructure locators or mail secrets.
|
||||||
|
|
||||||
|
Password-named business fields are intentionally retained. Only the
|
||||||
|
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = public_campaign_payload(value)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
server = payload.get("server")
|
||||||
|
if isinstance(server, dict):
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
config = server.get(protocol)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config.pop("password", None)
|
||||||
|
credentials = server.get("credentials")
|
||||||
|
if isinstance(credentials, dict):
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
config = credentials.get(protocol)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config.pop("password", None)
|
||||||
|
_sanitize_configuration_paths(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def public_source_filename(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
windows_path = PureWindowsPath(text)
|
||||||
|
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
|
||||||
|
template = payload.get("template")
|
||||||
|
source = template.get("source") if isinstance(template, dict) else None
|
||||||
|
if isinstance(source, dict):
|
||||||
|
for key in ("subject_path", "text_path", "html_path"):
|
||||||
|
if key in source:
|
||||||
|
source[key] = _public_configuration_path(source[key])
|
||||||
|
|
||||||
|
entries = payload.get("entries")
|
||||||
|
entry_source = entries.get("source") if isinstance(entries, dict) else None
|
||||||
|
if isinstance(entry_source, dict) and "path" in entry_source:
|
||||||
|
entry_source["path"] = _public_configuration_path(entry_source["path"])
|
||||||
|
|
||||||
|
attachments = payload.get("attachments")
|
||||||
|
if isinstance(attachments, dict):
|
||||||
|
if "base_path" in attachments:
|
||||||
|
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
|
||||||
|
base_paths = attachments.get("base_paths")
|
||||||
|
if isinstance(base_paths, list):
|
||||||
|
for item in base_paths:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
for key in ("path", "source"):
|
||||||
|
if key in item:
|
||||||
|
item[key] = _public_configuration_path(item[key])
|
||||||
|
_sanitize_attachment_rules(attachments.get("global"))
|
||||||
|
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
inline_entries = entries.get("inline")
|
||||||
|
if isinstance(inline_entries, list):
|
||||||
|
for entry in inline_entries:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
_sanitize_attachment_rules(entry.get("attachments"))
|
||||||
|
defaults = entries.get("defaults")
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
_sanitize_attachment_rules(defaults.get("attachments"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_attachment_rules(value: Any) -> None:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return
|
||||||
|
for rule in value:
|
||||||
|
if isinstance(rule, dict) and "base_dir" in rule:
|
||||||
|
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
|
||||||
|
|
||||||
|
|
||||||
|
def _public_configuration_path(value: Any) -> Any:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return value
|
||||||
|
text = value.strip()
|
||||||
|
windows_path = PureWindowsPath(text)
|
||||||
|
path = Path(text)
|
||||||
|
if windows_path.is_absolute():
|
||||||
|
return windows_path.name
|
||||||
|
if path.is_absolute() or text.startswith("~"):
|
||||||
|
return path.name
|
||||||
|
return value
|
||||||
@@ -38,6 +38,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignJobsResponse,
|
CampaignJobsResponse,
|
||||||
CampaignJobsDeltaResponse,
|
CampaignJobsDeltaResponse,
|
||||||
CampaignJobDetailResponse,
|
CampaignJobDetailResponse,
|
||||||
|
CampaignJobDiagnosticsResponse,
|
||||||
CampaignRetryJobsRequest,
|
CampaignRetryJobsRequest,
|
||||||
CampaignSendJobRequest,
|
CampaignSendJobRequest,
|
||||||
CampaignSendUnattemptedRequest,
|
CampaignSendUnattemptedRequest,
|
||||||
@@ -98,6 +99,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||||
|
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
CampaignPersistenceError,
|
CampaignPersistenceError,
|
||||||
@@ -1812,7 +1814,7 @@ def validate_version(
|
|||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return result
|
return public_campaign_payload(result)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
@@ -1847,7 +1849,7 @@ def build_version(
|
|||||||
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
|
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return result
|
return public_campaign_payload(result)
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1904,14 +1906,81 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
|
|||||||
return {
|
return {
|
||||||
**_job_summary_payload(job),
|
**_job_summary_payload(job),
|
||||||
"message_id_header": job.message_id_header,
|
"message_id_header": job.message_id_header,
|
||||||
"eml_local_path": job.eml_local_path,
|
|
||||||
"eml_storage_key": job.eml_storage_key,
|
|
||||||
"issues": job.issues_snapshot or [],
|
"issues": job.issues_snapshot or [],
|
||||||
"attachments": job.resolved_attachments or [],
|
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||||
"resolved_recipients": job.resolved_recipients or {},
|
"resolved_recipients": job.resolved_recipients or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_attempts_payload(
|
||||||
|
send_attempts: list[SendAttempt],
|
||||||
|
imap_attempts: list[ImapAppendAttempt],
|
||||||
|
*,
|
||||||
|
include_diagnostics: bool = False,
|
||||||
|
) -> dict[str, list[dict[str, object]]]:
|
||||||
|
smtp_payloads: list[dict[str, object]] = []
|
||||||
|
for attempt in send_attempts:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"id": attempt.id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"smtp_status_code": attempt.smtp_status_code,
|
||||||
|
"smtp_response": attempt.smtp_response,
|
||||||
|
"error_type": attempt.error_type,
|
||||||
|
"error_message": attempt.error_message,
|
||||||
|
"started_at": attempt.started_at,
|
||||||
|
"finished_at": attempt.finished_at,
|
||||||
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
smtp_payloads.append(payload)
|
||||||
|
|
||||||
|
imap_payloads: list[dict[str, object]] = []
|
||||||
|
for attempt in imap_attempts:
|
||||||
|
payload = {
|
||||||
|
"id": attempt.id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"folder": attempt.folder,
|
||||||
|
"error_message": attempt.error_message,
|
||||||
|
"created_at": attempt.created_at,
|
||||||
|
"updated_at": attempt.updated_at,
|
||||||
|
}
|
||||||
|
if include_diagnostics:
|
||||||
|
payload["claim_token"] = attempt.claim_token
|
||||||
|
imap_payloads.append(payload)
|
||||||
|
return {"smtp": smtp_payloads, "imap": imap_payloads}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_diagnostics_payload(
|
||||||
|
job: CampaignJob,
|
||||||
|
send_attempts: list[SendAttempt],
|
||||||
|
imap_attempts: list[ImapAppendAttempt],
|
||||||
|
) -> CampaignJobDiagnosticsResponse:
|
||||||
|
return CampaignJobDiagnosticsResponse(
|
||||||
|
job_id=job.id,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
campaign_version_id=job.campaign_version_id,
|
||||||
|
storage={
|
||||||
|
"eml_local_path": job.eml_local_path,
|
||||||
|
"eml_storage_key": job.eml_storage_key,
|
||||||
|
"eml_size_bytes": job.eml_size_bytes,
|
||||||
|
"eml_sha256": job.eml_sha256,
|
||||||
|
},
|
||||||
|
worker_claim={
|
||||||
|
"claim_token": job.claim_token,
|
||||||
|
"claimed_at": job.claimed_at,
|
||||||
|
"smtp_started_at": job.smtp_started_at,
|
||||||
|
"outcome_unknown_at": job.outcome_unknown_at,
|
||||||
|
},
|
||||||
|
attempts=_job_attempts_payload(
|
||||||
|
send_attempts,
|
||||||
|
imap_attempts,
|
||||||
|
include_diagnostics=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _review_metadata(
|
def _review_metadata(
|
||||||
session: Session,
|
session: Session,
|
||||||
version: CampaignVersion | None,
|
version: CampaignVersion | None,
|
||||||
@@ -2462,38 +2531,42 @@ def get_job_detail(
|
|||||||
)
|
)
|
||||||
return CampaignJobDetailResponse(
|
return CampaignJobDetailResponse(
|
||||||
job=_job_detail_payload(job),
|
job=_job_detail_payload(job),
|
||||||
attempts={
|
attempts=_job_attempts_payload(send_attempts, imap_attempts),
|
||||||
"smtp": [
|
|
||||||
{
|
|
||||||
"id": attempt.id,
|
|
||||||
"attempt_number": attempt.attempt_number,
|
|
||||||
"status": attempt.status,
|
|
||||||
"claim_token": attempt.claim_token,
|
|
||||||
"smtp_status_code": attempt.smtp_status_code,
|
|
||||||
"smtp_response": attempt.smtp_response,
|
|
||||||
"error_type": attempt.error_type,
|
|
||||||
"error_message": attempt.error_message,
|
|
||||||
"started_at": attempt.started_at,
|
|
||||||
"finished_at": attempt.finished_at,
|
|
||||||
}
|
|
||||||
for attempt in send_attempts
|
|
||||||
],
|
|
||||||
"imap": [
|
|
||||||
{
|
|
||||||
"id": attempt.id,
|
|
||||||
"attempt_number": attempt.attempt_number,
|
|
||||||
"status": attempt.status,
|
|
||||||
"folder": attempt.folder,
|
|
||||||
"error_message": attempt.error_message,
|
|
||||||
"created_at": attempt.created_at,
|
|
||||||
"updated_at": attempt.updated_at,
|
|
||||||
}
|
|
||||||
for attempt in imap_attempts
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{campaign_id}/jobs/{job_id}/diagnostics",
|
||||||
|
response_model=CampaignJobDiagnosticsResponse,
|
||||||
|
)
|
||||||
|
def get_job_diagnostics(
|
||||||
|
campaign_id: str,
|
||||||
|
job_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
|
||||||
|
):
|
||||||
|
"""Return infrastructure details only to campaign operators/admins."""
|
||||||
|
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
job = session.get(CampaignJob, job_id)
|
||||||
|
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
|
||||||
|
send_attempts = (
|
||||||
|
session.query(SendAttempt)
|
||||||
|
.filter(SendAttempt.job_id == job.id)
|
||||||
|
.order_by(SendAttempt.attempt_number.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
imap_attempts = (
|
||||||
|
session.query(ImapAppendAttempt)
|
||||||
|
.filter(ImapAppendAttempt.job_id == job.id)
|
||||||
|
.order_by(ImapAppendAttempt.attempt_number.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/summary")
|
@router.get("/{campaign_id}/summary")
|
||||||
def campaign_summary(
|
def campaign_summary(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.response_security import (
|
||||||
|
public_campaign_configuration,
|
||||||
|
public_campaign_payload,
|
||||||
|
public_source_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignCreateRequest(BaseModel):
|
class CampaignCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -80,7 +86,6 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
schema_version: str
|
schema_version: str
|
||||||
source_filename: str | None = None
|
source_filename: str | None = None
|
||||||
source_base_path: str | None = None
|
|
||||||
workflow_state: str = "editing"
|
workflow_state: str = "editing"
|
||||||
current_flow: str = "manual"
|
current_flow: str = "manual"
|
||||||
current_step: str | None = None
|
current_step: str | None = None
|
||||||
@@ -100,10 +105,25 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
execution_snapshot_hash: str | None = None
|
execution_snapshot_hash: str | None = None
|
||||||
execution_snapshot_at: datetime | None = None
|
execution_snapshot_at: datetime | None = None
|
||||||
|
|
||||||
|
@field_validator("source_filename", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def remove_source_directory(cls, value: Any) -> str | None:
|
||||||
|
return public_source_filename(value)
|
||||||
|
|
||||||
|
@field_validator("validation_summary", "build_summary", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def remove_internal_summary_fields(cls, value: Any) -> Any:
|
||||||
|
return public_campaign_payload(value)
|
||||||
|
|
||||||
|
|
||||||
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
||||||
raw_json: dict[str, Any]
|
raw_json: dict[str, Any]
|
||||||
|
|
||||||
|
@field_validator("raw_json", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def remove_internal_configuration_fields(cls, value: Any) -> Any:
|
||||||
|
return public_campaign_configuration(value)
|
||||||
|
|
||||||
|
|
||||||
class CampaignPartialValidationResponse(BaseModel):
|
class CampaignPartialValidationResponse(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
@@ -351,6 +371,15 @@ class CampaignJobDetailResponse(BaseModel):
|
|||||||
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignJobDiagnosticsResponse(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
campaign_id: str
|
||||||
|
campaign_version_id: str
|
||||||
|
storage: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
worker_claim: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class CampaignRetryJobsRequest(BaseModel):
|
class CampaignRetryJobsRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
|||||||
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
||||||
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
||||||
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
||||||
|
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
|
||||||
|
JobSendStatus.CLAIMED.value,
|
||||||
|
JobSendStatus.SENDING.value,
|
||||||
|
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
JobSendStatus.FAILED_TEMPORARY.value,
|
||||||
|
JobSendStatus.FAILED_PERMANENT.value,
|
||||||
|
JobSendStatus.CANCELLED.value,
|
||||||
|
}
|
||||||
|
INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES = {
|
||||||
|
JobQueueStatus.CANCELLED.value,
|
||||||
|
JobQueueStatus.SENDING.value,
|
||||||
|
JobQueueStatus.PAUSED.value,
|
||||||
|
}
|
||||||
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
|
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
|
||||||
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
|
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
|
||||||
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
|
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
|
||||||
@@ -324,73 +337,73 @@ def _celery_enqueue_append_sent_job(job_id: str) -> None:
|
|||||||
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
|
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
|
||||||
|
|
||||||
|
|
||||||
def queue_campaign_jobs(
|
def _ensure_campaign_execution_snapshot(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
version: CampaignVersion,
|
||||||
tenant_id: str,
|
) -> None:
|
||||||
campaign_id: str,
|
|
||||||
version_id: str | None = None,
|
|
||||||
enqueue_celery: bool = True,
|
|
||||||
include_warnings: bool = True,
|
|
||||||
dry_run: bool = False,
|
|
||||||
) -> QueueCampaignResult:
|
|
||||||
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
|
|
||||||
|
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
|
||||||
version = _get_current_version(session, campaign, version_id=version_id)
|
|
||||||
_ensure_version_validated_and_locked(version)
|
|
||||||
try:
|
try:
|
||||||
ensure_execution_snapshot(session, version)
|
ensure_execution_snapshot(session, version)
|
||||||
except ExecutionSnapshotError as exc:
|
except ExecutionSnapshotError as exc:
|
||||||
raise QueueingError(str(exc)) from exc
|
raise QueueingError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_validation_statuses(*, include_warnings: bool) -> set[str]:
|
||||||
allowed_validation = {JobValidationStatus.READY.value}
|
allowed_validation = {JobValidationStatus.READY.value}
|
||||||
if include_warnings:
|
if include_warnings:
|
||||||
allowed_validation.add(JobValidationStatus.WARNING.value)
|
allowed_validation.add(JobValidationStatus.WARNING.value)
|
||||||
|
return allowed_validation
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_jobs_for_queue(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
version_id: str,
|
||||||
|
) -> list[CampaignJob]:
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id)
|
.filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id,
|
||||||
|
CampaignJob.campaign_version_id == version_id,
|
||||||
|
)
|
||||||
.order_by(CampaignJob.entry_index.asc())
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
if not jobs:
|
if not jobs:
|
||||||
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
|
raise QueueingError(
|
||||||
|
"Campaign version has no jobs. Build messages before queueing."
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
queued: list[CampaignJob] = []
|
|
||||||
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
def _initial_queue_disposition(
|
||||||
skipped_count = 0
|
job: CampaignJob,
|
||||||
blocked_count = 0
|
*,
|
||||||
for job in jobs:
|
allowed_validation: set[str],
|
||||||
if job.send_status in SMTP_ACCEPTED_STATUSES | {
|
reviewed_needs_review_keys: set[str],
|
||||||
JobSendStatus.CLAIMED.value,
|
) -> str:
|
||||||
JobSendStatus.SENDING.value,
|
if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
|
||||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
# Initial queueing never doubles as retry/reconciliation. Those states
|
||||||
JobSendStatus.FAILED_TEMPORARY.value,
|
# require the dedicated explicit actions below.
|
||||||
JobSendStatus.FAILED_PERMANENT.value,
|
return "skipped"
|
||||||
JobSendStatus.CANCELLED.value,
|
if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
|
||||||
}:
|
return "skipped"
|
||||||
# Initial queueing never doubles as retry/reconciliation. Those
|
|
||||||
# states require the dedicated explicit actions below.
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}:
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
validation_allowed = job.validation_status in allowed_validation or (
|
validation_allowed = job.validation_status in allowed_validation or (
|
||||||
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||||
and _job_review_key(job) in reviewed_needs_review_keys
|
and _job_review_key(job) in reviewed_needs_review_keys
|
||||||
)
|
)
|
||||||
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
||||||
blocked_count += 1
|
return "blocked"
|
||||||
continue
|
|
||||||
if not job.eml_local_path and not job.eml_storage_key:
|
if not job.eml_local_path and not job.eml_storage_key:
|
||||||
job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing."
|
job.last_error = (
|
||||||
blocked_count += 1
|
"Job has no generated EML path/storage key. Rebuild with write_eml "
|
||||||
continue
|
"enabled before queueing."
|
||||||
|
)
|
||||||
|
return "blocked"
|
||||||
|
return "queued"
|
||||||
|
|
||||||
queued.append(job)
|
|
||||||
if not dry_run:
|
def _mark_campaign_job_queued(session: Session, job: CampaignJob) -> None:
|
||||||
job.queue_status = JobQueueStatus.QUEUED.value
|
job.queue_status = JobQueueStatus.QUEUED.value
|
||||||
job.send_status = JobSendStatus.QUEUED.value
|
job.send_status = JobSendStatus.QUEUED.value
|
||||||
job.queued_at = _utcnow()
|
job.queued_at = _utcnow()
|
||||||
@@ -401,7 +414,43 @@ def queue_campaign_jobs(
|
|||||||
job.last_error = None
|
job.last_error = None
|
||||||
session.add(job)
|
session.add(job)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_campaign_jobs_for_queue(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
allowed_validation: set[str],
|
||||||
|
reviewed_needs_review_keys: set[str],
|
||||||
|
dry_run: bool,
|
||||||
|
) -> tuple[list[CampaignJob], int, int]:
|
||||||
|
queued: list[CampaignJob] = []
|
||||||
|
skipped_count = 0
|
||||||
|
blocked_count = 0
|
||||||
|
for job in jobs:
|
||||||
|
disposition = _initial_queue_disposition(
|
||||||
|
job,
|
||||||
|
allowed_validation=allowed_validation,
|
||||||
|
reviewed_needs_review_keys=reviewed_needs_review_keys,
|
||||||
|
)
|
||||||
|
if disposition == "skipped":
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
if disposition == "blocked":
|
||||||
|
blocked_count += 1
|
||||||
|
continue
|
||||||
|
queued.append(job)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
|
_mark_campaign_job_queued(session, job)
|
||||||
|
return queued, skipped_count, blocked_count
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_campaign_queue(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
queued: list[CampaignJob],
|
||||||
|
) -> None:
|
||||||
if queued:
|
if queued:
|
||||||
previous_status = campaign.status
|
previous_status = campaign.status
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
@@ -420,11 +469,58 @@ def queue_campaign_jobs(
|
|||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
enqueued_count = 0
|
|
||||||
if _should_enqueue_celery(enqueue_celery) and not dry_run:
|
def _enqueue_campaign_jobs(queued: list[CampaignJob], *, enabled: bool) -> int:
|
||||||
|
if not enabled:
|
||||||
|
return 0
|
||||||
for job in queued:
|
for job in queued:
|
||||||
_celery_enqueue_send_job(job.id)
|
_celery_enqueue_send_job(job.id)
|
||||||
enqueued_count += 1
|
return len(queued)
|
||||||
|
|
||||||
|
|
||||||
|
def queue_campaign_jobs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
|
enqueue_celery: bool = True,
|
||||||
|
include_warnings: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> QueueCampaignResult:
|
||||||
|
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
|
||||||
|
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
|
version = _get_current_version(session, campaign, version_id=version_id)
|
||||||
|
_ensure_version_validated_and_locked(version)
|
||||||
|
_ensure_campaign_execution_snapshot(session, version)
|
||||||
|
allowed_validation = _queue_validation_statuses(
|
||||||
|
include_warnings=include_warnings
|
||||||
|
)
|
||||||
|
jobs = _campaign_jobs_for_queue(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
version_id=version.id,
|
||||||
|
)
|
||||||
|
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
||||||
|
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
|
||||||
|
session,
|
||||||
|
jobs=jobs,
|
||||||
|
allowed_validation=allowed_validation,
|
||||||
|
reviewed_needs_review_keys=reviewed_needs_review_keys,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
_persist_campaign_queue(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
queued=queued,
|
||||||
|
)
|
||||||
|
enqueued_count = _enqueue_campaign_jobs(
|
||||||
|
queued,
|
||||||
|
enabled=_should_enqueue_celery(enqueue_celery) and not dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
return QueueCampaignResult(
|
return QueueCampaignResult(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||||
|
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_template_key(raw: str) -> str:
|
||||||
|
key = raw.strip()
|
||||||
|
if key.startswith("fields."):
|
||||||
|
key = key.removeprefix("fields.")
|
||||||
|
elif key.startswith("local."):
|
||||||
|
key = "local::" + key.removeprefix("local.")
|
||||||
|
elif key.startswith("global."):
|
||||||
|
key = "global::" + key.removeprefix("global.")
|
||||||
|
|
||||||
|
if key.startswith("local::") or key.startswith("global::"):
|
||||||
|
return key
|
||||||
|
if key.startswith("local:"):
|
||||||
|
return "local::" + key.removeprefix("local:")
|
||||||
|
if key.startswith("global:"):
|
||||||
|
return "global::" + key.removeprefix("global:")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(
|
||||||
|
template: str,
|
||||||
|
values: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
keep_missing: bool = True,
|
||||||
|
) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
key = normalize_template_key(match.group(1))
|
||||||
|
if key in values:
|
||||||
|
value = values[key]
|
||||||
|
return "" if value is None else str(value)
|
||||||
|
return match.group(0) if keep_missing else ""
|
||||||
|
|
||||||
|
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||||
|
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||||
|
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||||
|
if not text:
|
||||||
|
return set()
|
||||||
|
return {
|
||||||
|
normalize_template_key(match.group(1))
|
||||||
|
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||||
|
for match in pattern.finditer(text)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
|
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
|
||||||
|
|
||||||
@@ -89,6 +90,8 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
|
|||||||
assert row["latest_smtp_response"] == "2.0.0 queued"
|
assert row["latest_smtp_response"] == "2.0.0 queued"
|
||||||
assert row["latest_imap_status"] == "appended"
|
assert row["latest_imap_status"] == "appended"
|
||||||
assert row["latest_imap_folder"] == "Sent"
|
assert row["latest_imap_folder"] == "Sent"
|
||||||
|
assert "eml_storage_key" not in row
|
||||||
|
assert "eml_local_path" not in row
|
||||||
|
|
||||||
|
|
||||||
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
|
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
|
||||||
@@ -102,3 +105,15 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
|
|||||||
|
|
||||||
assert latest["job-1"].attempt_number == 3
|
assert latest["job-1"].attempt_number == 3
|
||||||
assert latest["job-2"].attempt_number == 2
|
assert latest["job-2"].attempt_number == 2
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignReportProjectionTests(unittest.TestCase):
|
||||||
|
def test_evidence_projection(self) -> None:
|
||||||
|
test_job_evidence_row_contains_transport_and_message_evidence()
|
||||||
|
|
||||||
|
def test_latest_attempt_projection(self) -> None:
|
||||||
|
test_latest_by_job_id_keeps_highest_attempt_number()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.dev.mock_campaign import (
|
||||||
|
_MockSendBatch,
|
||||||
|
_mock_send_steps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockCampaignStepTests(unittest.TestCase):
|
||||||
|
def test_preview_marks_delivery_steps_as_skipped(self):
|
||||||
|
validation = SimpleNamespace(ok=True)
|
||||||
|
build = SimpleNamespace(
|
||||||
|
built_count=2,
|
||||||
|
queueable_count=2,
|
||||||
|
needs_review_count=0,
|
||||||
|
blocked_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
steps = _mock_send_steps(
|
||||||
|
validation_report=validation,
|
||||||
|
validation_payload={"ok": True},
|
||||||
|
build_report=build,
|
||||||
|
send_batch=_MockSendBatch(results=[]),
|
||||||
|
send=False,
|
||||||
|
append_sent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[step["status"] for step in steps],
|
||||||
|
["ok", "ok", "skipped", "skipped"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delivery_failures_require_review(self):
|
||||||
|
validation = SimpleNamespace(ok=False)
|
||||||
|
build = SimpleNamespace(
|
||||||
|
built_count=1,
|
||||||
|
queueable_count=0,
|
||||||
|
needs_review_count=1,
|
||||||
|
blocked_count=0,
|
||||||
|
)
|
||||||
|
batch = _MockSendBatch(
|
||||||
|
results=[],
|
||||||
|
failed_count=1,
|
||||||
|
imap_failed_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
steps = _mock_send_steps(
|
||||||
|
validation_report=validation,
|
||||||
|
validation_payload={"ok": False},
|
||||||
|
build_report=build,
|
||||||
|
send_batch=batch,
|
||||||
|
send=True,
|
||||||
|
append_sent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[step["status"] for step in steps],
|
||||||
|
["needs_review", "needs_review", "needs_review", "needs_review"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import stat
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||||
|
from govoplan_campaign.backend.router import (
|
||||||
|
_job_attempts_payload,
|
||||||
|
_job_detail_payload,
|
||||||
|
_job_diagnostics_payload,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime(2026, 7, 21, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _job() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
entry_index=1,
|
||||||
|
entry_id="recipient-1",
|
||||||
|
recipient_email="person@example.test",
|
||||||
|
subject="Subject",
|
||||||
|
message_id_header="<message@example.test>",
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
queue_status="claimed",
|
||||||
|
send_status="sending",
|
||||||
|
imap_status="pending",
|
||||||
|
eml_size_bytes=123,
|
||||||
|
eml_sha256="sha256",
|
||||||
|
eml_local_path="/runtime/campaign/job-1.eml",
|
||||||
|
eml_storage_key="campaign/job-1.eml",
|
||||||
|
claim_token="job-claim-secret",
|
||||||
|
attempt_count=1,
|
||||||
|
last_error=None,
|
||||||
|
queued_at=_now(),
|
||||||
|
claimed_at=_now(),
|
||||||
|
smtp_started_at=_now(),
|
||||||
|
outcome_unknown_at=None,
|
||||||
|
sent_at=None,
|
||||||
|
created_at=_now(),
|
||||||
|
updated_at=_now(),
|
||||||
|
issues_snapshot=[],
|
||||||
|
resolved_attachments=[
|
||||||
|
{
|
||||||
|
"filename": "public.pdf",
|
||||||
|
"local_path": "/tmp/materialized/public.pdf",
|
||||||
|
"storage_key": "private/blob-key",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
resolved_recipients={"to": [{"email": "person@example.test"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_attempt() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="smtp-attempt-1",
|
||||||
|
attempt_number=1,
|
||||||
|
status="started",
|
||||||
|
claim_token="attempt-claim-secret",
|
||||||
|
smtp_status_code=None,
|
||||||
|
smtp_response=None,
|
||||||
|
error_type=None,
|
||||||
|
error_message=None,
|
||||||
|
started_at=_now(),
|
||||||
|
finished_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_attempt() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="imap-attempt-1",
|
||||||
|
attempt_number=1,
|
||||||
|
status="claimed",
|
||||||
|
claim_token="imap-claim-secret",
|
||||||
|
folder="Sent",
|
||||||
|
error_message=None,
|
||||||
|
created_at=_now(),
|
||||||
|
updated_at=_now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
|
||||||
|
source = {
|
||||||
|
"campaign_file": "/tmp/campaign.json",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"subject": "Public",
|
||||||
|
"eml_path": "/tmp/message.eml",
|
||||||
|
"managed": {"storage_bucket": "private", "storage_key": "object"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = public_campaign_payload(source)
|
||||||
|
|
||||||
|
assert result == {"messages": [{"subject": "Public", "managed": {}}]}
|
||||||
|
assert source["messages"][0]["eml_path"] == "/tmp/message.eml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> None:
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
version_number=1,
|
||||||
|
schema_version="1",
|
||||||
|
source_filename="/srv/govoplan/imports/campaign.json",
|
||||||
|
source_base_path="/private/import/path",
|
||||||
|
workflow_state="editing",
|
||||||
|
current_flow="manual",
|
||||||
|
current_step=None,
|
||||||
|
is_complete=False,
|
||||||
|
editor_state={},
|
||||||
|
autosaved_at=None,
|
||||||
|
published_at=None,
|
||||||
|
locked_at=None,
|
||||||
|
locked_by_user_id=None,
|
||||||
|
user_lock_state=None,
|
||||||
|
user_locked_at=None,
|
||||||
|
user_locked_by_user_id=None,
|
||||||
|
created_at=_now(),
|
||||||
|
updated_at=_now(),
|
||||||
|
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
|
||||||
|
build_summary={"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}]},
|
||||||
|
execution_snapshot_hash=None,
|
||||||
|
execution_snapshot_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = CampaignVersionResponse.model_validate(version).model_dump()
|
||||||
|
|
||||||
|
assert "source_base_path" not in payload
|
||||||
|
assert payload["source_filename"] == "campaign.json"
|
||||||
|
assert payload["validation_summary"] == {"ok": True}
|
||||||
|
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
|
||||||
|
|
||||||
|
detail = CampaignVersionDetailResponse.model_validate(
|
||||||
|
SimpleNamespace(
|
||||||
|
**version.__dict__,
|
||||||
|
raw_json={
|
||||||
|
"campaign": {"title": "Public"},
|
||||||
|
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
|
||||||
|
"server": {
|
||||||
|
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
|
||||||
|
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
|
||||||
|
"credentials": {
|
||||||
|
"smtp": {"username": "sender", "password": "legacy-smtp-secret"},
|
||||||
|
"imap": {"username": "archive", "password": "legacy-imap-secret"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"archives": [{"password": "business-zip-password"}],
|
||||||
|
"template": {
|
||||||
|
"source": {
|
||||||
|
"subject_path": "/srv/govoplan/templates/subject.txt",
|
||||||
|
"html_path": "templates/body.html",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entries": {"source": {"type": "csv", "path": "C:\\imports\\recipients.csv"}},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": "/srv/govoplan/attachments",
|
||||||
|
"base_paths": [
|
||||||
|
{"name": "Shared", "path": "/mnt/shared/campaign", "source": "/mnt/shared"}
|
||||||
|
],
|
||||||
|
"global": [{"base_dir": "/srv/govoplan/attachments/global", "file_filter": "*.pdf"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).model_dump()
|
||||||
|
assert detail["raw_json"] == {
|
||||||
|
"campaign": {"title": "Public"},
|
||||||
|
"files": [{"filename": "public.pdf"}],
|
||||||
|
"server": {
|
||||||
|
"smtp": {"host": "smtp.example.invalid"},
|
||||||
|
"imap": {"host": "imap.example.invalid"},
|
||||||
|
"credentials": {
|
||||||
|
"smtp": {"username": "sender"},
|
||||||
|
"imap": {"username": "archive"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"archives": [{"password": "business-zip-password"}],
|
||||||
|
"template": {
|
||||||
|
"source": {
|
||||||
|
"subject_path": "subject.txt",
|
||||||
|
"html_path": "templates/body.html",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entries": {"source": {"type": "csv", "path": "recipients.csv"}},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": "attachments",
|
||||||
|
"base_paths": [{"name": "Shared", "path": "campaign", "source": "shared"}],
|
||||||
|
"global": [{"base_dir": "global", "file_filter": "*.pdf"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
|
||||||
|
job_payload = _job_detail_payload(_job()) # type: ignore[arg-type]
|
||||||
|
attempts = _job_attempts_payload([_smtp_attempt()], [_imap_attempt()]) # type: ignore[list-item]
|
||||||
|
|
||||||
|
assert "eml_local_path" not in job_payload
|
||||||
|
assert "eml_storage_key" not in job_payload
|
||||||
|
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
|
||||||
|
assert "claim_token" not in attempts["smtp"][0]
|
||||||
|
assert "claim_token" not in attempts["imap"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
|
||||||
|
diagnostics = _job_diagnostics_payload( # type: ignore[arg-type, list-item]
|
||||||
|
_job(),
|
||||||
|
[_smtp_attempt()],
|
||||||
|
[_imap_attempt()],
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
assert diagnostics["storage"]["eml_local_path"] == "/runtime/campaign/job-1.eml"
|
||||||
|
assert diagnostics["storage"]["eml_storage_key"] == "campaign/job-1.eml"
|
||||||
|
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
|
||||||
|
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
|
||||||
|
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostics_permission_is_operator_only_by_default() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
|
||||||
|
|
||||||
|
permission_scopes = {permission.scope for permission in PERMISSIONS}
|
||||||
|
role_permissions = {role.slug: set(role.permissions) for role in ROLE_TEMPLATES}
|
||||||
|
|
||||||
|
assert "campaigns:diagnostic:read" in permission_scopes
|
||||||
|
assert "campaigns:diagnostic:read" in role_permissions["campaign_sender"]
|
||||||
|
assert "campaigns:diagnostic:read" not in role_permissions["campaign_manager"]
|
||||||
|
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None:
|
||||||
|
from govoplan_campaign.backend.persistence import campaigns as persistence
|
||||||
|
|
||||||
|
snapshots = tmp_path / "snapshots"
|
||||||
|
output = tmp_path / "generated"
|
||||||
|
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots)
|
||||||
|
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output)
|
||||||
|
path = persistence._write_campaign_snapshot( # type: ignore[arg-type]
|
||||||
|
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700
|
||||||
|
assert stat.S_IMODE(output.stat().st_mode) == 0o700
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseSecurityTests(unittest.TestCase):
|
||||||
|
def test_public_payload(self) -> None:
|
||||||
|
test_public_payload_recursively_removes_infrastructure_locators()
|
||||||
|
|
||||||
|
def test_version_response(self) -> None:
|
||||||
|
test_version_response_omits_source_base_path_and_sanitizes_summaries()
|
||||||
|
|
||||||
|
def test_ordinary_job_response(self) -> None:
|
||||||
|
test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics()
|
||||||
|
|
||||||
|
def test_operator_diagnostics(self) -> None:
|
||||||
|
test_operator_diagnostics_include_claim_and_storage_details()
|
||||||
|
|
||||||
|
def test_operator_permission(self) -> None:
|
||||||
|
test_diagnostics_permission_is_operator_only_by_default()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
JobBuildStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
JobSendStatus,
|
||||||
|
JobValidationStatus,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
|
_queue_validation_statuses,
|
||||||
|
_select_campaign_jobs_for_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.added: list[object] = []
|
||||||
|
|
||||||
|
def add(self, value: object) -> None:
|
||||||
|
self.added.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _job(entry_id: str, **overrides):
|
||||||
|
values = {
|
||||||
|
"entry_id": entry_id,
|
||||||
|
"entry_index": int(entry_id),
|
||||||
|
"send_status": JobSendStatus.NOT_QUEUED.value,
|
||||||
|
"queue_status": JobQueueStatus.DRAFT.value,
|
||||||
|
"validation_status": JobValidationStatus.READY.value,
|
||||||
|
"build_status": JobBuildStatus.BUILT.value,
|
||||||
|
"eml_local_path": f"{entry_id}.eml",
|
||||||
|
"eml_storage_key": None,
|
||||||
|
"last_error": "old error",
|
||||||
|
"queued_at": None,
|
||||||
|
"claimed_at": "claimed",
|
||||||
|
"claim_token": "token",
|
||||||
|
"smtp_started_at": "started",
|
||||||
|
"outcome_unknown_at": "unknown",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignQueueSelectionTests(unittest.TestCase):
|
||||||
|
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
|
||||||
|
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
|
||||||
|
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
|
||||||
|
blocked_validation = _job(
|
||||||
|
"3",
|
||||||
|
validation_status=JobValidationStatus.WARNING.value,
|
||||||
|
)
|
||||||
|
blocked_missing_eml = _job(
|
||||||
|
"4",
|
||||||
|
eml_local_path=None,
|
||||||
|
eml_storage_key=None,
|
||||||
|
)
|
||||||
|
ready = _job("5")
|
||||||
|
reviewed = _job(
|
||||||
|
"6",
|
||||||
|
validation_status=JobValidationStatus.NEEDS_REVIEW.value,
|
||||||
|
)
|
||||||
|
session = FakeSession()
|
||||||
|
|
||||||
|
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
|
||||||
|
session,
|
||||||
|
jobs=[
|
||||||
|
skipped_send,
|
||||||
|
skipped_queue,
|
||||||
|
blocked_validation,
|
||||||
|
blocked_missing_eml,
|
||||||
|
ready,
|
||||||
|
reviewed,
|
||||||
|
],
|
||||||
|
allowed_validation=_queue_validation_statuses(include_warnings=False),
|
||||||
|
reviewed_needs_review_keys={"6"},
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queued, [ready, reviewed])
|
||||||
|
self.assertEqual(skipped_count, 2)
|
||||||
|
self.assertEqual(blocked_count, 2)
|
||||||
|
self.assertIn("generated EML", blocked_missing_eml.last_error)
|
||||||
|
self.assertEqual(session.added, [ready, reviewed])
|
||||||
|
for job in queued:
|
||||||
|
self.assertEqual(job.queue_status, JobQueueStatus.QUEUED.value)
|
||||||
|
self.assertEqual(job.send_status, JobSendStatus.QUEUED.value)
|
||||||
|
self.assertIsNotNone(job.queued_at)
|
||||||
|
self.assertIsNone(job.claimed_at)
|
||||||
|
self.assertIsNone(job.claim_token)
|
||||||
|
self.assertIsNone(job.smtp_started_at)
|
||||||
|
self.assertIsNone(job.outcome_unknown_at)
|
||||||
|
self.assertIsNone(job.last_error)
|
||||||
|
|
||||||
|
def test_dry_run_does_not_mutate_queueable_job(self):
|
||||||
|
warning = _job(
|
||||||
|
"1",
|
||||||
|
validation_status=JobValidationStatus.WARNING.value,
|
||||||
|
)
|
||||||
|
session = FakeSession()
|
||||||
|
|
||||||
|
queued, skipped_count, blocked_count = _select_campaign_jobs_for_queue(
|
||||||
|
session,
|
||||||
|
jobs=[warning],
|
||||||
|
allowed_validation=_queue_validation_statuses(include_warnings=True),
|
||||||
|
reviewed_needs_review_keys=set(),
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queued, [warning])
|
||||||
|
self.assertEqual((skipped_count, blocked_count), (0, 0))
|
||||||
|
self.assertEqual(warning.queue_status, JobQueueStatus.DRAFT.value)
|
||||||
|
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
|
||||||
|
self.assertEqual(session.added, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
|
from govoplan_campaign.backend.attachments.resolver import resolve_entry_attachments
|
||||||
|
from govoplan_campaign.backend.template_rendering import (
|
||||||
|
find_unresolved_placeholders,
|
||||||
|
normalize_template_key,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignTemplateRenderingTests(unittest.TestCase):
|
||||||
|
def test_template_key_aliases_have_shared_normalization(self) -> None:
|
||||||
|
cases = {
|
||||||
|
" name ": "name",
|
||||||
|
"fields.name": "name",
|
||||||
|
"local.name": "local::name",
|
||||||
|
"local:name": "local::name",
|
||||||
|
"local::name": "local::name",
|
||||||
|
"global.name": "global::name",
|
||||||
|
"global:name": "global::name",
|
||||||
|
"global::name": "global::name",
|
||||||
|
}
|
||||||
|
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(normalize_template_key(raw), expected)
|
||||||
|
|
||||||
|
def test_rendering_preserves_both_syntaxes_none_and_missing_values(self) -> None:
|
||||||
|
values = {
|
||||||
|
"name": "Ada",
|
||||||
|
"local::reference": 42,
|
||||||
|
"global::empty": None,
|
||||||
|
}
|
||||||
|
template = "${fields.name}|{{ local.reference }}|${global:empty}|${missing}|{{ global.absent }}"
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
render_template(template, values),
|
||||||
|
"Ada|42||${missing}|{{ global.absent }}",
|
||||||
|
)
|
||||||
|
self.assertEqual(render_template(template, values, keep_missing=False), "Ada|42|||")
|
||||||
|
|
||||||
|
def test_rendering_unescapes_literal_dollar_placeholders(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
render_template(r"\${literal\}|${known}", {"known": "resolved"}, keep_missing=False),
|
||||||
|
"${literal}|resolved",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unresolved_placeholders_are_normalized_and_deduplicated(self) -> None:
|
||||||
|
unresolved = find_unresolved_placeholders(
|
||||||
|
r"${fields.missing} {{ local:name }} ${global.other} {{local::name}} \${escaped\}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(unresolved, {"missing", "local::name", "global::other"})
|
||||||
|
self.assertEqual(find_unresolved_placeholders(None), set())
|
||||||
|
|
||||||
|
def test_attachment_resolution_keeps_missing_placeholders(self) -> None:
|
||||||
|
config = CampaignConfig.model_validate(
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "template-parity", "name": "Template parity", "mode": "test"},
|
||||||
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": ".",
|
||||||
|
"global": [
|
||||||
|
{
|
||||||
|
"base_dir": ".",
|
||||||
|
"file_filter": "${missing}.pdf",
|
||||||
|
"required": False,
|
||||||
|
"missing_behavior": "continue",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.test", "type": "to"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
resolution = resolve_entry_attachments(
|
||||||
|
config=config,
|
||||||
|
campaign_file=Path(temp_dir) / "campaign.json",
|
||||||
|
entry=config.entries.inline[0], # type: ignore[index]
|
||||||
|
entry_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resolution.attachments[0].file_filter, "${missing}.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||||
import { apiDownload, apiFetch } from "./client";
|
import { apiDownload, apiFetch } from "./client";
|
||||||
|
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
|
export type {
|
||||||
|
AccessDecisionProvenanceItem,
|
||||||
|
ResourceAccessExplanationUser as AccessExplanationUser,
|
||||||
|
ResourceAccessExplanationResponse
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type CampaignListResponse =
|
export type CampaignListResponse =
|
||||||
CampaignListItem[] |
|
CampaignListItem[] |
|
||||||
@@ -22,27 +28,6 @@ export type CampaignShare = {
|
|||||||
|
|
||||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||||
export type AccessExplanationUser = {
|
|
||||||
id: string;
|
|
||||||
account_id?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
display_name?: string | null;
|
|
||||||
};
|
|
||||||
export type AccessDecisionProvenanceItem = {
|
|
||||||
kind: string;
|
|
||||||
id?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
tenant_id?: string | null;
|
|
||||||
source?: string | null;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
export type ResourceAccessExplanationResponse = {
|
|
||||||
user: AccessExplanationUser;
|
|
||||||
resource_type: string;
|
|
||||||
resource_id: string;
|
|
||||||
action: string;
|
|
||||||
provenance: AccessDecisionProvenanceItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CampaignUpdatePayload = {
|
export type CampaignUpdatePayload = {
|
||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
@@ -995,20 +980,6 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
|||||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchResourceAccessExplanation(
|
|
||||||
settings: ApiSettings,
|
|
||||||
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
|
|
||||||
: Promise<ResourceAccessExplanationResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
user_id: options.userId,
|
|
||||||
resource_type: options.resourceType,
|
|
||||||
resource_id: options.resourceId,
|
|
||||||
action: options.action
|
|
||||||
});
|
|
||||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
|
||||||
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||||
return response.shares;
|
return response.shares;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
|
|
||||||
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
|
|
||||||
export { DataGridEmptyAction, DataGridRowActions };
|
|
||||||
export default DataGrid;
|
|
||||||
@@ -12,7 +12,7 @@ import VersionLine from "./components/VersionLine";
|
|||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
|
|||||||
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
import { getBool, getText, updateNested } from "./utils/draftEditor";
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
import FieldValueInput from "./components/FieldValueInput";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||||
import type { CampaignListItem } from "../../types";
|
import type { CampaignListItem } from "../../types";
|
||||||
|
|
||||||
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
width: 70,
|
width: 70,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (campaign) =>
|
render: (campaign) => <TableActionGroup actions={[{
|
||||||
<Link
|
id: "open",
|
||||||
to={`/campaigns/${campaign.id}`}
|
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
|
||||||
className="btn btn-primary admin-icon-button"
|
icon: <ExternalLink aria-hidden="true" />,
|
||||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
|
variant: "primary",
|
||||||
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
|
onClick: () => navigate(`/campaigns/${campaign.id}`)
|
||||||
|
}]} />
|
||||||
<ExternalLink aria-hidden="true" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ExternalLink, LockKeyhole } from "lucide-react";
|
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
@@ -10,8 +10,8 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
|||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
lockCampaignVersionTemporarily,
|
lockCampaignVersionTemporarily,
|
||||||
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
|
|||||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||||
|
|
||||||
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const campaign = data.campaign;
|
const campaign = data.campaign;
|
||||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||||
@@ -204,17 +205,17 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-grid overview-summary-grid">
|
<div className="metric-grid inside">
|
||||||
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||||
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||||
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||||
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-table-surface version-history-table-surface">
|
<div className="admin-table-surface version-history-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
rows={versions}
|
rows={versions}
|
||||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
|
||||||
getRowKey={(version) => version.id}
|
getRowKey={(version) => version.id}
|
||||||
initialSort={{ columnId: "version", direction: "desc" }}
|
initialSort={{ columnId: "version", direction: "desc" }}
|
||||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||||
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
|
|||||||
return typeof value === "string" ? value : fallback;
|
return typeof value === "string" ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||||
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||||
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 260,
|
width: 150,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (version) => {
|
render: (version) => {
|
||||||
const isCurrent = version.id === currentVersionId;
|
const isCurrent = version.id === currentVersionId;
|
||||||
return (
|
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
|
||||||
<div className="button-row compact-actions">
|
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
||||||
<Link
|
return <TableActionGroup actions={[
|
||||||
to={`send?version=${version.id}`}
|
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
|
||||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
|
||||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
|
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
|
||||||
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
|
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
|
||||||
|
]} />;
|
||||||
<ExternalLink aria-hidden="true" />
|
|
||||||
</Link>
|
|
||||||
{isCurrent && (isTemporaryUserLockedVersion(version) ?
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
|
|
||||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
|
|
||||||
</> :
|
|
||||||
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
|
|
||||||
<Button
|
|
||||||
className="admin-icon-button"
|
|
||||||
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
|
||||||
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
|
|
||||||
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
|
|
||||||
|
|
||||||
<LockKeyhole aria-hidden="true" />
|
|
||||||
</Button> :
|
|
||||||
null)}
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
@@ -392,12 +375,3 @@ function lockDialogLabel(pending: PendingLockAction): string {
|
|||||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryTile({ label, value }: {label: string;value: string | number;}) {
|
|
||||||
return (
|
|
||||||
<div className="summary-tile">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Check, RotateCcw, Search, X } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
downloadCampaignJobsCsv,
|
downloadCampaignJobsCsv,
|
||||||
@@ -15,13 +16,13 @@ import {
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn, type DataGridListOption } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||||
@@ -358,18 +359,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 250,
|
width: 190,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const id = String(row.id ?? "");
|
const id = String(row.id ?? "");
|
||||||
const status = String(row.send_status ?? "");
|
const status = String(row.send_status ?? "");
|
||||||
return (
|
return <TableActionGroup actions={[
|
||||||
<div className="button-row compact-actions">
|
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
|
||||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
|
||||||
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
|
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
]} />;
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
@@ -473,8 +473,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
|
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
|
||||||
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
||||||
</label>
|
</label>
|
||||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
|
<ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
|
||||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
|
<ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -526,39 +526,21 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns: DataGridColumn<Record<string, unknown>>[] = [
|
||||||
|
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
|
||||||
|
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
|
||||||
|
kind === "imap" ?
|
||||||
|
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
|
||||||
|
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
|
||||||
|
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
|
||||||
|
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
|
||||||
|
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="attempt-history-section">
|
<section className="attempt-history-section">
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<div className="attempt-history-wrap">
|
<DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
|
||||||
<table className="attempt-history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
||||||
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
|
|
||||||
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
|
|
||||||
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
|
|
||||||
<th>i18n:govoplan-campaign.finished.355bcc57</th>
|
|
||||||
<th>i18n:govoplan-campaign.result.5faa59d4</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row, index) =>
|
|
||||||
<tr key={String(row.id ?? `${kind}-${index}`)}>
|
|
||||||
<td>{String(row.attempt_number ?? index + 1)}</td>
|
|
||||||
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
|
|
||||||
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
|
|
||||||
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
|
|
||||||
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
|
|
||||||
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
|
|
||||||
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
|
|
||||||
{String(row.smtp_response ?? row.error_message ?? "—")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import CampaignAccessCard from "./components/CampaignAccessCard";
|
|||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { hasScope } from "@govoplan/core-webui";
|
import { hasScope } from "@govoplan/core-webui";
|
||||||
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
|
import { RetentionPolicyEditor } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { ToggleSwitch } from "@govoplan/core-webui";
|
|||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { SegmentedControl } from "@govoplan/core-webui";
|
import { SegmentedControl } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
import { TableActionGroup } from "@govoplan/core-webui";
|
||||||
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
|
|||||||
<span>{translateText(emptyText)}</span>
|
<span>{translateText(emptyText)}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<TableActionGroup actions={[
|
||||||
type="button"
|
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
|
||||||
className="admin-icon-button recipient-address-inline-button"
|
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
|
||||||
disabled={disabled}
|
]} />
|
||||||
aria-label="Edit addresses"
|
|
||||||
title="Edit addresses"
|
|
||||||
onClick={onEdit}>
|
|
||||||
<Pencil aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="admin-icon-button recipient-address-inline-button"
|
|
||||||
disabled={!copiedText}
|
|
||||||
aria-label="Copy addresses"
|
|
||||||
title="Copy addresses"
|
|
||||||
onClick={onCopy}>
|
|
||||||
<Copy aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
{column.allowMultiple && draftAddresses.length === 0 &&
|
{column.allowMultiple && draftAddresses.length === 0 &&
|
||||||
<div className="recipient-address-empty-row">
|
<div className="recipient-address-empty-row">
|
||||||
<p className="muted recipient-address-empty">{column.emptyText}</p>
|
<p className="muted recipient-address-empty">{column.emptyText}</p>
|
||||||
<div className="data-grid-row-actions data-grid-empty-row-actions">
|
<TableActionGroup
|
||||||
<Button
|
className="data-grid-empty-row-actions"
|
||||||
type="button"
|
actions={[{
|
||||||
variant="primary"
|
id: "add",
|
||||||
className="data-grid-row-action is-add"
|
label: translatedAddLabel,
|
||||||
aria-label={translatedAddLabel}
|
icon: <Plus size={16} aria-hidden="true" />,
|
||||||
title={translatedAddLabel}
|
variant: "primary",
|
||||||
disabled={!canAdd}
|
disabled: !canAdd,
|
||||||
onClick={() => addAddress()}>
|
onClick: () => addAddress()
|
||||||
<Plus size={16} aria-hidden="true" />
|
}]}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{draftAddresses.map((address, addressIndex) =>
|
{draftAddresses.map((address, addressIndex) =>
|
||||||
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
aria-label={`${column.label} email ${addressIndex + 1}`}
|
aria-label={`${column.label} email ${addressIndex + 1}`}
|
||||||
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
|
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
|
||||||
{column.allowMultiple &&
|
{column.allowMultiple &&
|
||||||
<div className="data-grid-row-actions recipient-address-line-actions">
|
<TableActionGroup
|
||||||
<Button
|
className="recipient-address-line-actions"
|
||||||
type="button"
|
actions={[
|
||||||
variant="primary"
|
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
|
||||||
className="data-grid-row-action is-add"
|
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
|
||||||
aria-label={translatedAddLabel}
|
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
|
||||||
title={translatedAddLabel}
|
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
|
||||||
disabled={!canAdd}
|
]}
|
||||||
onClick={() => addAddress(addressIndex)}>
|
/>
|
||||||
<Plus size={16} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="data-grid-row-action is-reorder"
|
|
||||||
aria-label={translatedMoveUpLabel}
|
|
||||||
title={translatedMoveUpLabel}
|
|
||||||
disabled={locked || addressIndex === 0}
|
|
||||||
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
|
|
||||||
<ArrowUp size={16} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="data-grid-row-action is-reorder"
|
|
||||||
aria-label={translatedMoveDownLabel}
|
|
||||||
title={translatedMoveDownLabel}
|
|
||||||
disabled={locked || addressIndex >= draftAddresses.length - 1}
|
|
||||||
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
|
|
||||||
<ArrowDown size={16} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="danger"
|
|
||||||
className="data-grid-row-action is-remove"
|
|
||||||
aria-label={translatedRemoveLabel}
|
|
||||||
title={translatedRemoveLabel}
|
|
||||||
disabled={locked}
|
|
||||||
onClick={() => removeAddress(addressIndex)}>
|
|
||||||
<Trash2 size={16} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
|
|||||||
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
||||||
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="admin-table-surface recipient-import-preview-surface">
|
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||||
<table className="recipient-import-preview-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Fields</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
|
|
||||||
<tr key={`${recipient.contact_id}-${recipient.email}`}>
|
|
||||||
<td>{index + 1}</td>
|
|
||||||
<td>{recipient.display_name}</td>
|
|
||||||
<td>{recipient.email}</td>
|
|
||||||
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</Dialog>);
|
</Dialog>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
|
||||||
|
|
||||||
|
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
|
||||||
|
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
|
||||||
|
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
|
||||||
|
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
|
||||||
|
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
|
||||||
|
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
|
||||||
|
];
|
||||||
|
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
type RecipientImportDialogProps = {
|
type RecipientImportDialogProps = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
replaceColumnMapping(nextMapping);
|
replaceColumnMapping(nextMapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
|
||||||
|
id: `${columnIndex}-${header}`,
|
||||||
|
header,
|
||||||
|
columnIndex,
|
||||||
|
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
|
||||||
|
}));
|
||||||
|
type MappingRow = (typeof mappingRows)[number];
|
||||||
|
const mappingColumns: DataGridColumn<MappingRow>[] = [
|
||||||
|
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
|
||||||
|
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
|
||||||
|
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
|
||||||
|
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
|
||||||
|
];
|
||||||
|
|
||||||
|
type PreviewRow = RecipientImportPreview["rows"][number];
|
||||||
|
const previewColumns: DataGridColumn<PreviewRow>[] = [
|
||||||
|
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
|
||||||
|
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
|
||||||
|
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
|
||||||
|
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
|
||||||
|
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
|
||||||
|
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
|
||||||
|
];
|
||||||
|
|
||||||
const stepContent = activeStep === "upload" ?
|
const stepContent = activeStep === "upload" ?
|
||||||
<>
|
<>
|
||||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||||
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-table-surface recipient-import-preview-surface">
|
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
|
||||||
<table className="recipient-import-preview-table recipient-import-mapping-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>i18n:govoplan-campaign.column.65ba00e9</th>
|
|
||||||
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
|
|
||||||
<th>i18n:govoplan-campaign.content.4f9be057</th>
|
|
||||||
<th>i18n:govoplan-campaign.field.c326a466</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table?.headers.map((header, columnIndex) => {
|
|
||||||
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
|
||||||
return (
|
|
||||||
<tr key={`${columnIndex}-${header}`}>
|
|
||||||
<td><strong>{header}</strong></td>
|
|
||||||
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
|
|
||||||
<td>
|
|
||||||
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
|
|
||||||
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{mapping.kind === "field" &&
|
|
||||||
<select
|
|
||||||
value={mapping.fieldName ?? ""}
|
|
||||||
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
|
|
||||||
|
|
||||||
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
|
|
||||||
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
{mapping.kind === "new_field" &&
|
|
||||||
<input
|
|
||||||
value={mapping.newFieldName ?? ""}
|
|
||||||
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
|
|
||||||
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>);
|
|
||||||
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</> :
|
</> :
|
||||||
activeStep === "preview" ?
|
activeStep === "preview" ?
|
||||||
<>
|
<>
|
||||||
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
{preview.fieldNamesToCreate.length > 0 &&
|
{preview.fieldNamesToCreate.length > 0 &&
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
||||||
}
|
}
|
||||||
<div className="admin-table-surface recipient-import-preview-surface">
|
<DataGrid
|
||||||
<table className="recipient-import-preview-table">
|
id="campaign-recipient-import-preview"
|
||||||
<thead>
|
className="recipient-import-preview-grid"
|
||||||
<tr>
|
rows={preview.rows.slice(0, 20)}
|
||||||
<th>#</th>
|
columns={previewColumns}
|
||||||
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
|
getRowKey={(row) => String(row.rowNumber)}
|
||||||
<th>i18n:govoplan-campaign.name.709a2322</th>
|
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
|
||||||
<th>i18n:govoplan-campaign.fields.e8b68527</th>
|
/>
|
||||||
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
|
|
||||||
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{preview.rows.slice(0, 20).map((row) =>
|
|
||||||
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
|
|
||||||
<td>{row.rowNumber}</td>
|
|
||||||
<td>{formatAddressList(row.addresses.to)}</td>
|
|
||||||
<td>{row.name}</td>
|
|
||||||
<td>{Object.keys(row.fields).length}</td>
|
|
||||||
<td>{row.patterns.length}</td>
|
|
||||||
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</> :
|
</> :
|
||||||
@@ -1743,6 +1649,14 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
|
|||||||
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
|
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolvedFile = ImportFileLinkResolution["files"][number];
|
||||||
|
const fileColumns: DataGridColumn<ResolvedFile>[] = [
|
||||||
|
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
|
||||||
|
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
|
||||||
|
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
|
||||||
|
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="recipient-import-file-step">
|
<div className="recipient-import-file-step">
|
||||||
<div className="recipient-import-file-actions">
|
<div className="recipient-import-file-actions">
|
||||||
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="admin-table-surface recipient-import-preview-surface">
|
<DataGrid
|
||||||
<table className="recipient-import-preview-table recipient-import-file-link-table">
|
id="campaign-recipient-import-file-links"
|
||||||
<thead>
|
rows={resolution.files}
|
||||||
<tr>
|
columns={fileColumns}
|
||||||
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
|
getRowKey={(file) => file.id}
|
||||||
<th>i18n:govoplan-campaign.path.519e3913</th>
|
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
|
||||||
<th>i18n:govoplan-campaign.size.b7152342</th>
|
/>
|
||||||
<th>i18n:govoplan-campaign.status.bae7d5be</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{resolution.files.length === 0 ?
|
|
||||||
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
|
|
||||||
resolution.files.map((file) =>
|
|
||||||
<tr key={file.id}>
|
|
||||||
<td><strong>{file.filename}</strong></td>
|
|
||||||
<td>{file.display_path}</td>
|
|
||||||
<td>{formatImportBytes(file.size_bytes)}</td>
|
|
||||||
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>);
|
</div>);
|
||||||
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
|
|||||||
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
|
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
|
||||||
const visibleRows = tableRows.slice(0, 15);
|
const visibleRows = tableRows.slice(0, 15);
|
||||||
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
|
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
|
||||||
|
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
|
||||||
|
type RawRow = (typeof rows)[number];
|
||||||
|
const columns: DataGridColumn<RawRow>[] = [
|
||||||
|
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
|
||||||
|
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
|
||||||
|
id: `column-${columnIndex + 1}`,
|
||||||
|
header: String(columnIndex + 1),
|
||||||
|
width: "minmax(140px, 1fr)",
|
||||||
|
minWidth: 120,
|
||||||
|
resizable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.cells[columnIndex] ?? ""
|
||||||
|
}))
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-surface recipient-import-preview-surface">
|
<DataGrid
|
||||||
<table className="recipient-import-preview-table recipient-import-raw-table">
|
id="campaign-recipient-import-raw"
|
||||||
<thead>
|
className="recipient-import-raw-grid"
|
||||||
<tr>
|
rows={rows}
|
||||||
<th>#</th>
|
columns={columns}
|
||||||
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
|
getRowKey={(row) => String(row.rowIndex)}
|
||||||
</tr>
|
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
|
||||||
</thead>
|
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
|
||||||
<tbody>
|
initialFit="content"
|
||||||
{visibleRows.length === 0 ?
|
/>);
|
||||||
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
|
|
||||||
visibleRows.map((row, rowIndex) =>
|
|
||||||
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
|
|
||||||
<td>{rowIndex + 1}</td>
|
|
||||||
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Search,
|
||||||
Send,
|
Send,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
X,
|
X,
|
||||||
@@ -36,8 +37,8 @@ import {
|
|||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||||
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
@@ -1940,7 +1941,7 @@ reviewedKeys: Set<string>)
|
|||||||
},
|
},
|
||||||
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
||||||
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted">—</span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
|
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted">—</span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => openMessage(row)}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2132,7 +2133,7 @@ function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): Da
|
|||||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
||||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
||||||
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }];
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2146,7 +2147,7 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
|
|||||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
|
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")} → ${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
|
||||||
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
|
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ApiSettings } from "../../../types";
|
|||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
||||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
|
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
|
||||||
import { KeyRound } from "lucide-react";
|
import { KeyRound, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
fetchResourceAccessExplanation,
|
fetchResourceAccessExplanation,
|
||||||
getCampaignShares,
|
getCampaignShares,
|
||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
type CampaignShareTargets,
|
type CampaignShareTargets,
|
||||||
type ResourceAccessExplanationResponse } from
|
type ResourceAccessExplanationResponse } from
|
||||||
"../../../api/campaigns";
|
"../../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { StatusBadge, TableActionGroup, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
type TargetType = "user" | "group";
|
type TargetType = "user" | "group";
|
||||||
|
|
||||||
export default function CampaignAccessCard({
|
export default function CampaignAccessCard({
|
||||||
@@ -196,7 +196,7 @@ export default function CampaignAccessCard({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
|
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, resizable: false, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "remove", label: "i18n:govoplan-campaign.remove.e963907d", icon: <Trash2 aria-hidden="true" />, variant: "danger", onClick: () => setRevokeTarget(row) }]} /> }],
|
||||||
[targetMap]);
|
[targetMap]);
|
||||||
|
|
||||||
if (available === false) return null;
|
if (available === false) return null;
|
||||||
@@ -221,76 +221,11 @@ export default function CampaignAccessCard({
|
|||||||
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||||
<ResourceAccessExplanationContent loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
|
<ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceAccessExplanationContent({
|
|
||||||
loading,
|
|
||||||
explanation,
|
|
||||||
fallbackResourceLabel
|
|
||||||
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
|
|
||||||
if (loading) return <p className="muted small-note">i18n:govoplan-campaign.loading_access_explanation.04a7c934</p>;
|
|
||||||
if (!explanation) return null;
|
|
||||||
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
|
|
||||||
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-grid compact responsive-form-grid">
|
|
||||||
<div><span className="form-label">i18n:govoplan-campaign.user.9f8a2389</span><p>{userLabel}</p></div>
|
|
||||||
<div><span className="form-label">i18n:govoplan-campaign.resource.d1c626a9</span><p>{resourceLabel}</p></div>
|
|
||||||
<div><span className="form-label">i18n:govoplan-campaign.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
|
|
||||||
<div><span className="form-label">i18n:govoplan-campaign.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
|
|
||||||
</div>
|
|
||||||
{explanation.provenance.length === 0 ?
|
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.no_access_evidence_was_returned.84a21e4e</p> :
|
|
||||||
<div className="admin-assignment-grid">
|
|
||||||
{explanation.provenance.map((item, index) =>
|
|
||||||
<div key={`${item.kind}:${item.id ?? index}`}>
|
|
||||||
<strong>{provenanceKindLabel(item.kind)}</strong>
|
|
||||||
<div className="muted small-note">
|
|
||||||
{item.source || "i18n:govoplan-campaign.no_source.6dcf9723"}
|
|
||||||
{item.id && <> · <code>{item.id}</code></>}
|
|
||||||
</div>
|
|
||||||
{item.label && <p>{item.label}</p>}
|
|
||||||
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function provenanceKindLabel(kind: string): string {
|
|
||||||
switch (kind) {
|
|
||||||
case "resource":
|
|
||||||
return "i18n:govoplan-campaign.resource.d1c626a9";
|
|
||||||
case "owner":
|
|
||||||
return "i18n:govoplan-campaign.owner.89ff3122";
|
|
||||||
case "share":
|
|
||||||
return "i18n:govoplan-campaign.share.09ca55ca";
|
|
||||||
case "policy":
|
|
||||||
return "i18n:govoplan-campaign.policy.0b779a05";
|
|
||||||
case "role":
|
|
||||||
return "i18n:govoplan-campaign.role.b5b4a5a2";
|
|
||||||
case "right":
|
|
||||||
return "i18n:govoplan-campaign.permission.2f81a22d";
|
|
||||||
default:
|
|
||||||
return kind;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProvenanceDetails(details: Record<string, unknown>): string {
|
|
||||||
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatProvenanceValue(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return "i18n:govoplan-campaign.none.6eef6648";
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, RotateCcw, Send } from "lucide-react";
|
||||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||||
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
|
import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
||||||
|
|
||||||
type OperatorRow = {
|
type OperatorRow = {
|
||||||
@@ -165,16 +165,13 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 270,
|
width: 150,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) =>
|
render: (row) => <TableActionGroup actions={[
|
||||||
<div className="button-row compact-actions">
|
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
|
||||||
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
|
{ id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
|
||||||
<ExternalLink />
|
{ id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
|
||||||
</Button>
|
]} />
|
||||||
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
|
|
||||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
}],
|
}],
|
||||||
[busy, navigate]);
|
[busy, navigate]);
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "@govoplan/core-webui";
|
|
||||||
export type { RetentionPolicyTargetOption } from "@govoplan/core-webui";
|
|
||||||
@@ -4,10 +4,10 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { FieldLabel } from "@govoplan/core-webui";
|
import { FieldLabel } from "@govoplan/core-webui";
|
||||||
import { StatusBadge, i18nMessage } from "@govoplan/core-webui";
|
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { usePlatformLanguage } from "@govoplan/core-webui";
|
import { usePlatformLanguage } from "@govoplan/core-webui";
|
||||||
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
|
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
|
||||||
type TemplateRecord = {
|
type TemplateRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -97,10 +97,12 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
|
|||||||
width: 70,
|
width: 70,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (template) =>
|
render: (template) => <TableActionGroup actions={[{
|
||||||
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}>
|
id: "open",
|
||||||
<ExternalLink />
|
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name }),
|
||||||
</Button>
|
icon: <ExternalLink aria-hidden="true" />,
|
||||||
|
onClick: () => openTemplate(template.id)
|
||||||
|
}]} />
|
||||||
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -115,8 +117,7 @@ function templateFieldColumns(): DataGridColumn<{id: string;field: string;source
|
|||||||
return [
|
return [
|
||||||
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
|
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
|
||||||
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
|
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
|
||||||
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required },
|
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }];
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { ModuleSubnav } from "@govoplan/core-webui";
|
|
||||||
|
|
||||||
export type { ModuleSubnavGroup, ModuleSubnavItem } from "@govoplan/core-webui";
|
|
||||||
export default ModuleSubnav;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CampaignWorkspaceSection } from "../types";
|
import type { CampaignWorkspaceSection } from "../types";
|
||||||
import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
|
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
|
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
|
||||||
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
|
|
||||||
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
|
|
||||||
.small-note { font-size: 12px; }
|
|
||||||
|
|
||||||
.wizard-action-grid {
|
.wizard-action-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -80,7 +77,6 @@
|
|||||||
.standalone-wizard-body { min-height: auto; }
|
.standalone-wizard-body { min-height: auto; }
|
||||||
|
|
||||||
/* Editable campaign data surfaces. */
|
/* Editable campaign data surfaces. */
|
||||||
.responsive-form-grid { align-items: start; }
|
|
||||||
.json-edit-block {
|
.json-edit-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -92,7 +88,6 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.danger-text { color: var(--danger-text); }
|
|
||||||
.section-mini-heading {
|
.section-mini-heading {
|
||||||
margin: 18px 0 8px;
|
margin: 18px 0 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -112,24 +107,8 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.placeholder-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.placeholder-stack span {
|
|
||||||
display: block;
|
|
||||||
border: 1px dashed var(--line-dark);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.template-editor-grid { grid-template-columns: 1fr; }
|
.template-editor-grid { grid-template-columns: 1fr; }
|
||||||
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Campaign data layout refinement. */
|
/* Campaign data layout refinement. */
|
||||||
@@ -183,16 +162,6 @@
|
|||||||
.direct-attachment-table td:nth-child(3) { width: 180px; }
|
.direct-attachment-table td:nth-child(3) { width: 180px; }
|
||||||
.direct-attachment-table th:last-child,
|
.direct-attachment-table th:last-child,
|
||||||
.direct-attachment-table td:last-child { width: 96px; }
|
.direct-attachment-table td:last-child { width: 96px; }
|
||||||
.table-action-cell {
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.empty-table-cell {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 18px 14px !important;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.field-editor-table th:nth-child(1),
|
.field-editor-table th:nth-child(1),
|
||||||
.field-editor-table td:nth-child(1) { min-width: 80px; }
|
.field-editor-table td:nth-child(1) { min-width: 80px; }
|
||||||
.field-editor-table th:nth-child(2),
|
.field-editor-table th:nth-child(2),
|
||||||
@@ -257,9 +226,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.compact-detail-list {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.global-values-preview {
|
.global-values-preview {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -322,28 +288,9 @@
|
|||||||
.campaigns-page .card-body {
|
.campaigns-page .card-body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.campaign-table-wrap {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.campaign-table thead,
|
|
||||||
.campaign-table thead th {
|
|
||||||
top: 64px;
|
|
||||||
}
|
|
||||||
.campaign-table th:first-child,
|
|
||||||
.campaign-table td:first-child {
|
|
||||||
padding-left: 24px;
|
|
||||||
}
|
|
||||||
.campaign-table th:last-child,
|
|
||||||
.campaign-table td:last-child {
|
|
||||||
padding-right: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-workspace .section-sidebar {
|
.module-workspace .section-sidebar {
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
}
|
}
|
||||||
.settings-dashboard-grid {
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.queue-pressure-section {
|
.queue-pressure-section {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
@@ -374,13 +321,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
.module-big-number {
|
|
||||||
display: block;
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.stacked-actions {
|
.stacked-actions {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -439,10 +379,6 @@
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.small-text {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compact-chip-row {
|
.compact-chip-row {
|
||||||
gap: 0.28rem;
|
gap: 0.28rem;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
@@ -550,13 +486,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.overview-config-actions a { text-decoration: none; }
|
.overview-config-actions a { text-decoration: none; }
|
||||||
.overview-summary-grid {
|
|
||||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.overview-config-grid,
|
.overview-config-grid { grid-template-columns: 1fr; }
|
||||||
.overview-summary-grid { grid-template-columns: 1fr; }
|
|
||||||
.overview-config-card { grid-template-rows: auto auto auto auto; }
|
.overview-config-card { grid-template-rows: auto auto auto auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +511,7 @@
|
|||||||
box-shadow: var(--shadow-campaign-small);
|
box-shadow: var(--shadow-campaign-small);
|
||||||
}
|
}
|
||||||
.field-chip-button.used {
|
.field-chip-button.used {
|
||||||
background: var(--green-soft);
|
background: var(--success-soft);
|
||||||
border-color: var(--success-border-soft);
|
border-color: var(--success-border-soft);
|
||||||
color: var(--success-text-strong);
|
color: var(--success-text-strong);
|
||||||
}
|
}
|
||||||
@@ -745,14 +676,6 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.recipient-address-inline-button {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
.recipient-address-inline-button svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
.recipient-add-row td {
|
.recipient-add-row td {
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -771,15 +694,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Recipient editor compaction and reusable attachment rule overlay. */
|
/* Recipient editor compaction and reusable attachment rule overlay. */
|
||||||
.recipient-editor-table th:nth-child(1),
|
|
||||||
.recipient-editor-table td:nth-child(1) { width: 42px; }
|
|
||||||
.recipient-editor-table th:nth-child(2),
|
|
||||||
.recipient-editor-table td:nth-child(2) { min-width: 500px; }
|
|
||||||
.recipient-editor-table th:nth-child(3),
|
|
||||||
.recipient-editor-table td:nth-child(3) { min-width: 192px; }
|
|
||||||
.recipient-editor-table th:last-child,
|
|
||||||
.recipient-editor-table td:last-child { width: 123px; }
|
|
||||||
.recipient-editor-table td { vertical-align: top; }
|
|
||||||
.recipient-cell { white-space: normal !important; }
|
.recipient-cell { white-space: normal !important; }
|
||||||
.recipient-address-stack {
|
.recipient-address-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -805,9 +719,6 @@
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.recipient-field-input {
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
.attachment-summary-button {
|
.attachment-summary-button {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -861,8 +772,6 @@
|
|||||||
.attachment-sources-table td:last-child { width: 123px; }
|
.attachment-sources-table td:last-child { width: 123px; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.recipient-editor-table th:nth-child(2),
|
|
||||||
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
|
|
||||||
.recipient-address-stack { min-width: 260px; }
|
.recipient-address-stack { min-width: 260px; }
|
||||||
}
|
}
|
||||||
.attachment-rules-table th:nth-child(1),
|
.attachment-rules-table th:nth-child(1),
|
||||||
@@ -894,7 +803,7 @@
|
|||||||
.attachment-rules-main {
|
.attachment-rules-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.attachment-rules-table tr.is-choosing-file td {
|
.attachment-rules-table .data-grid-body-cell.is-choosing-file {
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
.attachment-file-browser-panel {
|
.attachment-file-browser-panel {
|
||||||
@@ -940,7 +849,7 @@
|
|||||||
.field-with-action input.chooser-display-input {
|
.field-with-action input.chooser-display-input {
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
border-color: var(--line-dark);
|
border-color: var(--line-dark);
|
||||||
color: var(--ink);
|
color: var(--text-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
caret-color: transparent;
|
caret-color: transparent;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -1130,7 +1039,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
color: var(--ink);
|
color: var(--text-strong);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.recipient-data-identity:hover .recipient-data-address {
|
.recipient-data-identity:hover .recipient-data-address {
|
||||||
@@ -1279,7 +1188,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-history-table .current-version-row td {
|
.version-history-table .data-grid-body-cell.current-version-row {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,7 +1206,7 @@
|
|||||||
border: 1px solid var(--line-subtle);
|
border: 1px solid var(--line-subtle);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--panel-glass);
|
background: var(--panel-glass);
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -1379,7 +1288,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--panel-glass);
|
background: var(--panel-glass);
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.55rem 0.7rem;
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-file-chip strong {
|
.attachment-file-chip strong {
|
||||||
@@ -1401,7 +1310,7 @@
|
|||||||
.message-preview-raw summary {
|
.message-preview-raw summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px), (max-height: 700px) {
|
@media (max-width: 720px), (max-height: 700px) {
|
||||||
@@ -1446,25 +1355,6 @@
|
|||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Managed attachment chooser refinements. */
|
|
||||||
.split-field-action {
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
.split-field-action > input,
|
|
||||||
.split-field-action > select,
|
|
||||||
.split-field-action > textarea {
|
|
||||||
border-top-right-radius: 0 !important;
|
|
||||||
border-bottom-right-radius: 0 !important;
|
|
||||||
}
|
|
||||||
.split-field-action > button,
|
|
||||||
.split-field-action > .button,
|
|
||||||
.split-field-action > .btn {
|
|
||||||
align-self: stretch;
|
|
||||||
margin-left: -1px;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
/* Review & Send workflow page. */
|
/* Review & Send workflow page. */
|
||||||
.review-send-page {
|
.review-send-page {
|
||||||
--review-flow-partial: var(--review-flow-purple, #9b86c7);
|
--review-flow-partial: var(--review-flow-purple, #9b86c7);
|
||||||
@@ -2415,29 +2305,6 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-secret {
|
|
||||||
display: block;
|
|
||||||
padding: 14px;
|
|
||||||
margin: 12px 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
user-select: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-json-preview {
|
|
||||||
max-height: 420px;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 12px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-error {
|
.dropdown-error {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
margin: 6px 8px 8px;
|
margin: 6px 8px 8px;
|
||||||
@@ -2654,54 +2521,20 @@
|
|||||||
min-height: 78px;
|
min-height: 78px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.recipient-import-preview-surface {
|
.recipient-import-preview-grid .data-grid-body-cell.is-invalid {
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.recipient-import-preview-table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 760px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.recipient-import-preview-table th,
|
|
||||||
.recipient-import-preview-table td {
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
padding: 9px 10px;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.recipient-import-preview-table th {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.recipient-import-preview-table td {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 13px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.recipient-import-preview-table tr.is-invalid td {
|
|
||||||
color: var(--danger-text);
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
.recipient-import-raw-table td {
|
|
||||||
|
.recipient-import-raw-grid .data-grid-body-cell {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.recipient-import-raw-table tr.is-header-row td {
|
|
||||||
|
.recipient-import-raw-grid .data-grid-body-cell.is-header-row {
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.recipient-import-mapping-table select,
|
|
||||||
.recipient-import-mapping-table input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
.recipient-import-mapping-table td:nth-child(2) {
|
|
||||||
max-width: 260px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.recipient-import-file-step {
|
.recipient-import-file-step {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -2767,77 +2600,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.admin-audit-grid .data-grid-scroll-region {
|
|
||||||
max-height: min(68vh, 720px);
|
|
||||||
}
|
|
||||||
.admin-audit-grid .data-grid-header-cell {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
.admin-details-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px 18px;
|
|
||||||
}
|
|
||||||
.admin-details-grid > div {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
.admin-details-grid dt {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.admin-details-grid dd {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.admin-managed-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--info-muted-bg);
|
|
||||||
color: var(--info-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.admin-governance-mode {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.admin-tenant-assignment-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 140px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.admin-settings-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
.admin-settings-form .card {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
@media (max-width: 850px) {
|
|
||||||
.admin-details-grid,
|
|
||||||
.admin-tenant-assignment-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-managed-notice {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid var(--info-muted-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--info-bg);
|
|
||||||
color: var(--info-text-deep);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.campaign-access-dialog > .dialog-header,
|
.campaign-access-dialog > .dialog-header,
|
||||||
.campaign-access-dialog > .dialog-footer {
|
.campaign-access-dialog > .dialog-footer {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
Reference in New Issue
Block a user