Compare commits
11 Commits
724ca779d6
...
78f52a36d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 78f52a36d4 | |||
| 0c4f198802 | |||
| c71fa3dc64 | |||
| 0a6064ec62 | |||
| fce6dd1138 | |||
| 4b9eb79065 | |||
| 7ea5bdb217 | |||
| f755cdf48d | |||
| 8965b27517 | |||
| 12036b1f36 | |||
| ad34365f6c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -332,6 +332,7 @@ cython_debug/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
webui/.review-preview-test-build/
|
||||
|
||||
# GovOPlaN shared ignore rules from govoplan-core
|
||||
# Local WebUI test/build scratch directories
|
||||
@@ -340,6 +341,7 @@ webui/.import-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
.review-preview-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.module-test-build/
|
||||
*.db
|
||||
|
||||
@@ -390,6 +390,27 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
||||
)
|
||||
|
||||
|
||||
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||
return config.attachments.send_without_attachments_behavior or (
|
||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||
)
|
||||
|
||||
|
||||
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||
messages = {
|
||||
Behavior.BLOCK: "No attachment file was resolved for this message, and campaign policy blocks sending without attachments.",
|
||||
Behavior.ASK: "No attachment file was resolved for this message. Confirm the message should still be sent.",
|
||||
Behavior.DROP: "No attachment file was resolved for this message. Campaign policy excludes messages without attachments.",
|
||||
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
||||
}
|
||||
return AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
|
||||
code="missing_attachment_coverage",
|
||||
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_one_config(
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
@@ -495,6 +516,15 @@ def resolve_entry_attachments(
|
||||
)
|
||||
|
||||
issues = [issue for item in resolved for issue in item.issues]
|
||||
missing_coverage_behavior = _send_without_attachments_behavior(config)
|
||||
if (
|
||||
entry.active
|
||||
and resolved
|
||||
and missing_coverage_behavior != Behavior.CONTINUE
|
||||
and sum(len(item.matches) for item in resolved) == 0
|
||||
and not any(issue.behavior == Behavior.BLOCK for issue in issues)
|
||||
):
|
||||
issues.append(_issue_for_missing_attachment_coverage(missing_coverage_behavior))
|
||||
return EntryAttachmentResolution(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
|
||||
@@ -404,11 +404,20 @@ class AttachmentsConfig(StrictModel):
|
||||
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
||||
allow_individual: bool = False
|
||||
send_without_attachments: bool = True
|
||||
send_without_attachments_behavior: Behavior | None = None
|
||||
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||
if self.send_without_attachments_behavior is None:
|
||||
self.send_without_attachments_behavior = Behavior.CONTINUE if self.send_without_attachments else Behavior.BLOCK
|
||||
else:
|
||||
self.send_without_attachments = self.send_without_attachments_behavior != Behavior.BLOCK
|
||||
return self
|
||||
|
||||
@property
|
||||
def individual_base_path_values(self) -> set[str]:
|
||||
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -507,6 +508,7 @@ def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, map
|
||||
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
issues.extend(_attachment_file_check_issues(config, campaign_path))
|
||||
issues.extend(_attachment_resolution_check_issues(config, campaign_path))
|
||||
issues.extend(_template_source_file_issues(config, campaign_path))
|
||||
return issues
|
||||
|
||||
@@ -536,6 +538,37 @@ def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -
|
||||
]
|
||||
|
||||
|
||||
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||
try:
|
||||
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||
except Exception as exc:
|
||||
return [
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"attachment_resolution_failed",
|
||||
f"attachment rules could not be resolved: {exc}",
|
||||
"/attachments",
|
||||
)
|
||||
]
|
||||
|
||||
issues: list[SemanticIssue] = []
|
||||
for entry in report.entries:
|
||||
entry_path = f"/entries/{entry.entry_id or entry.entry_index}"
|
||||
for issue in entry.issues:
|
||||
if any(issue is attachment_issue for attachment in entry.attachments for attachment_issue in attachment.issues):
|
||||
continue
|
||||
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, entry_path))
|
||||
for attachment in entry.attachments:
|
||||
attachment_path = (
|
||||
f"/attachments/global/{attachment.index}"
|
||||
if attachment.scope.value == "global"
|
||||
else f"{entry_path}/attachments/{attachment.index}"
|
||||
)
|
||||
for issue in attachment.issues:
|
||||
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, attachment_path))
|
||||
return issues
|
||||
|
||||
|
||||
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
for schema_path, raw_path in _iter_template_source_paths(config):
|
||||
|
||||
@@ -161,8 +161,6 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
|
||||
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
owner: Mapped["User"] = relationship("User")
|
||||
|
||||
|
||||
class CampaignVersion(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_versions"
|
||||
|
||||
@@ -50,6 +50,7 @@ class _PreparedCampaignSnapshot:
|
||||
self.raw_json = raw_json
|
||||
self.managed_files_by_local_path: dict[str, Any] = {}
|
||||
self.shared_assets: list[Any] = []
|
||||
self.candidate_assets: list[Any] = []
|
||||
|
||||
def cleanup(self) -> None:
|
||||
shutil.rmtree(self._directory, ignore_errors=True)
|
||||
@@ -107,6 +108,11 @@ class FilesCampaignIntegration:
|
||||
raise OptionalModuleUnavailable("Files module is not available")
|
||||
return self._delegate.current_version_and_blob(session, asset)
|
||||
|
||||
def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
if self._delegate is None:
|
||||
raise OptionalModuleUnavailable("Files module is not available")
|
||||
return self._delegate.share_assets_with_campaign(session, **kwargs)
|
||||
|
||||
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
|
||||
if self._delegate is not None:
|
||||
self._delegate.mark_job_attachment_uses_sent(session, job)
|
||||
|
||||
@@ -245,6 +245,26 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
|
||||
]
|
||||
|
||||
|
||||
def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
|
||||
if any(issue.code == "missing_attachment_coverage" for issue in issues):
|
||||
return
|
||||
issues.append(
|
||||
MessageIssue(
|
||||
severity="error",
|
||||
code="missing_attachment_coverage",
|
||||
message="No attachment file was resolved for this message, and sending without attachments is not allowed.",
|
||||
behavior=Behavior.BLOCK.value,
|
||||
source="attachments",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||
return config.attachments.send_without_attachments_behavior or (
|
||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(value: str | None, fallback: str) -> str:
|
||||
raw = value or fallback
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
||||
@@ -391,6 +411,7 @@ def _attach_files(
|
||||
for attachment in resolution.attachments:
|
||||
attachment.message_filenames = []
|
||||
attachment.zip_entry_names = []
|
||||
attachment.zip_filename = None
|
||||
|
||||
for attachment in resolution.attachments:
|
||||
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
||||
@@ -435,6 +456,7 @@ def _attach_files(
|
||||
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
|
||||
members,
|
||||
password,
|
||||
archive.method.value,
|
||||
)
|
||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
@@ -740,6 +762,14 @@ def _build_mime_message(
|
||||
values=rendered.values,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
|
||||
_append_no_attachment_coverage_issue(context.issues)
|
||||
return _MimeBuildResult(
|
||||
message=None,
|
||||
build_status=BuildStatus.BUILD_FAILED,
|
||||
validation_status=MessageValidationStatus.BLOCKED,
|
||||
attachment_count=0,
|
||||
)
|
||||
return _MimeBuildResult(
|
||||
message=message,
|
||||
build_status=BuildStatus.BUILT,
|
||||
|
||||
@@ -25,7 +25,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
|
||||
@@ -158,6 +158,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
||||
],
|
||||
"allow_individual": True,
|
||||
"send_without_attachments": False,
|
||||
"send_without_attachments_behavior": "block",
|
||||
"global": [],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
"missing_behavior": "ask",
|
||||
|
||||
@@ -11,7 +11,9 @@ from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
AppendSentRequest,
|
||||
BuildCampaignRequest,
|
||||
CampaignActionResponse,
|
||||
CampaignDeltaResponse,
|
||||
CampaignCreateRequest,
|
||||
CampaignUpdateRequest,
|
||||
@@ -37,6 +39,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignJobsDeltaResponse,
|
||||
CampaignJobDetailResponse,
|
||||
CampaignRetryJobsRequest,
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignListResponse,
|
||||
@@ -53,6 +56,12 @@ from govoplan_campaign.backend.schemas import (
|
||||
ValidateCampaignRequest,
|
||||
ReportEmailRequest,
|
||||
ReportEmailResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
QueueCampaignRequest,
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
@@ -118,7 +127,21 @@ from govoplan_campaign.backend.persistence.versions import (
|
||||
update_campaign_review_state,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
queue_campaign_jobs,
|
||||
queue_failed_jobs_for_retry,
|
||||
queue_unattempted_jobs,
|
||||
reconcile_job_outcome,
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
send_single_campaign_job,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
@@ -1728,6 +1751,7 @@ def validate_version(
|
||||
):
|
||||
_get_version_for_principal(session, version_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or ValidateCampaignRequest()
|
||||
try:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
@@ -1736,11 +1760,37 @@ def validate_version(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
|
||||
)
|
||||
link_result: CampaignAttachmentLinkMatchesResponse | None = None
|
||||
if payload.check_files and payload.link_unshared_matches:
|
||||
_require_permission(principal, "files:file:share")
|
||||
campaign = _get_campaign_for_tenant(session, version.campaign_id, principal.tenant_id)
|
||||
link_result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
dry_run=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": link_result.matched_file_count,
|
||||
"already_linked_file_count": link_result.already_linked_file_count,
|
||||
"linked_file_count": link_result.linked_file_count,
|
||||
"during_validation": True,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
result = validate_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
check_files=payload.check_files if payload else False,
|
||||
check_files=payload.check_files,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
@@ -1749,7 +1799,12 @@ def validate_version(
|
||||
action="campaign.validated",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")},
|
||||
details={
|
||||
"check_files": payload.check_files,
|
||||
"link_unshared_matches": payload.link_unshared_matches,
|
||||
"linked_file_count": link_result.linked_file_count if link_result else 0,
|
||||
"ok": result.get("ok"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
@@ -2685,30 +2740,6 @@ def revoke_campaign_share(
|
||||
return None
|
||||
|
||||
# Queue / delivery control -------------------------------------------------
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
AppendSentRequest,
|
||||
CampaignActionResponse,
|
||||
QueueCampaignRequest,
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
)
|
||||
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
queue_campaign_jobs,
|
||||
queue_failed_jobs_for_retry,
|
||||
queue_unattempted_jobs,
|
||||
reconcile_job_outcome,
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
|
||||
@@ -2818,6 +2849,45 @@ def send_unattempted_campaign_jobs(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse)
|
||||
def send_single_campaign_job_endpoint(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignSendJobRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendJobRequest()
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
|
||||
try:
|
||||
result = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.single_message_sent" if not payload.dry_run else "campaign.single_message_send_dry_run",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/resolve-outcome", response_model=CampaignActionResponse)
|
||||
def resolve_campaign_job_outcome(
|
||||
campaign_id: str,
|
||||
@@ -3056,6 +3126,7 @@ def append_sent(
|
||||
|
||||
class CampaignAttachmentPreviewRequest(BaseModel):
|
||||
include_unmatched: bool = True
|
||||
include_unlinked_candidates: bool = False
|
||||
campaign_json: dict[str, object] | None = None
|
||||
|
||||
|
||||
@@ -3063,10 +3134,31 @@ class CampaignAttachmentPreviewResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
shared_file_count: int
|
||||
candidate_file_count: int = 0
|
||||
matched_file_count: int = 0
|
||||
linked_file_count: int = 0
|
||||
unlinked_file_count: int = 0
|
||||
rules: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesRequest(BaseModel):
|
||||
campaign_json: dict[str, object] | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
matched_file_count: int
|
||||
already_linked_file_count: int
|
||||
linked_file_count: int
|
||||
dry_run: bool = False
|
||||
linked_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _file_preview(session: Session, asset) -> dict[str, object]:
|
||||
version, blob = files_integration().current_version_and_blob(session, asset)
|
||||
return {
|
||||
@@ -3080,29 +3172,36 @@ def _file_preview(session: Session, asset) -> dict[str, object]:
|
||||
"checksum_sha256": blob.checksum_sha256,
|
||||
"size_bytes": blob.size_bytes,
|
||||
"content_type": blob.content_type,
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse)
|
||||
def preview_campaign_attachments(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentPreviewRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
def _managed_preview_file(item: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"id": item["asset_id"],
|
||||
"version_id": item["version_id"],
|
||||
"blob_id": item["blob_id"],
|
||||
"display_path": item["display_path"],
|
||||
"filename": item["filename"],
|
||||
"owner_type": item["owner_type"],
|
||||
"owner_id": item["owner_id"],
|
||||
"checksum_sha256": item["checksum_sha256"],
|
||||
"size_bytes": item["size_bytes"],
|
||||
"content_type": item["content_type"],
|
||||
"linked_to_campaign": bool(item.get("linked_to_campaign", True)),
|
||||
}
|
||||
|
||||
payload = payload or CampaignAttachmentPreviewRequest()
|
||||
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
|
||||
def _attachment_preview_for_version(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
include_unmatched: bool,
|
||||
include_unlinked_candidates: bool,
|
||||
) -> CampaignAttachmentPreviewResponse:
|
||||
files = files_integration()
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
@@ -3111,32 +3210,31 @@ def preview_campaign_attachments(
|
||||
raw_json=raw,
|
||||
include_bytes=False,
|
||||
prefix="govoplan-managed-preview-",
|
||||
include_unlinked_candidates=include_unlinked_candidates,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
) as prepared:
|
||||
prepared_raw = load_campaign_json(prepared.path)
|
||||
config = load_campaign_config_from_json(session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
||||
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
|
||||
rules: list[dict[str, object]] = []
|
||||
matched_asset_ids: set[str] = set()
|
||||
linked_asset_ids: set[str] = set()
|
||||
linkable_by_id: dict[str, dict[str, object]] = {}
|
||||
|
||||
for entry in report.entries:
|
||||
for attachment in entry.attachments:
|
||||
managed_matches = files.managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path)
|
||||
matched_asset_ids.update(str(item["asset_id"]) for item in managed_matches)
|
||||
matches: list[dict[str, object]] = [
|
||||
{
|
||||
"id": item["asset_id"],
|
||||
"version_id": item["version_id"],
|
||||
"blob_id": item["blob_id"],
|
||||
"display_path": item["display_path"],
|
||||
"filename": item["filename"],
|
||||
"owner_type": item["owner_type"],
|
||||
"owner_id": item["owner_id"],
|
||||
"checksum_sha256": item["checksum_sha256"],
|
||||
"size_bytes": item["size_bytes"],
|
||||
"content_type": item["content_type"],
|
||||
}
|
||||
for item in managed_matches
|
||||
]
|
||||
matches: list[dict[str, object]] = []
|
||||
for item in managed_matches:
|
||||
asset_id = str(item["asset_id"])
|
||||
matched_asset_ids.add(asset_id)
|
||||
if bool(item.get("linked_to_campaign", True)):
|
||||
linked_asset_ids.add(asset_id)
|
||||
preview = _managed_preview_file(item)
|
||||
matches.append(preview)
|
||||
if not preview["linked_to_campaign"]:
|
||||
linkable_by_id.setdefault(asset_id, preview)
|
||||
if not matches:
|
||||
matches = [
|
||||
{
|
||||
@@ -3145,6 +3243,7 @@ def preview_campaign_attachments(
|
||||
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
|
||||
"owner_type": "legacy",
|
||||
"owner_id": "",
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
for match in attachment.matches
|
||||
]
|
||||
@@ -3167,6 +3266,8 @@ def preview_campaign_attachments(
|
||||
"zip_filename": attachment.zip_filename,
|
||||
"matches": matches,
|
||||
"match_count": len(matches),
|
||||
"linked_match_count": sum(1 for match in matches if bool(match.get("linked_to_campaign", True))),
|
||||
"unlinked_match_count": sum(1 for match in matches if not bool(match.get("linked_to_campaign", True))),
|
||||
"issues": [issue.model_dump(mode="json") for issue in attachment.issues],
|
||||
})
|
||||
|
||||
@@ -3175,6 +3276,140 @@ def preview_campaign_attachments(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
shared_file_count=len(prepared.shared_assets),
|
||||
candidate_file_count=len(getattr(prepared, "candidate_assets", prepared.shared_assets)),
|
||||
matched_file_count=len(matched_asset_ids),
|
||||
linked_file_count=len(linked_asset_ids),
|
||||
unlinked_file_count=len(linkable_by_id),
|
||||
rules=rules,
|
||||
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
|
||||
linkable_files=list(linkable_by_id.values()),
|
||||
unused_shared_files=[_file_preview(session, asset) for asset in unused] if include_unmatched else [],
|
||||
)
|
||||
|
||||
|
||||
def _link_campaign_attachment_matches(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
dry_run: bool = False,
|
||||
) -> CampaignAttachmentLinkMatchesResponse:
|
||||
preview = _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=False,
|
||||
include_unlinked_candidates=True,
|
||||
)
|
||||
file_ids = [str(item.get("id") or "") for item in preview.linkable_files if item.get("id")]
|
||||
linked_files: list[dict[str, object]] = []
|
||||
if file_ids and not dry_run:
|
||||
files = files_integration()
|
||||
shares = files.share_assets_with_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
file_ids=file_ids,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
)
|
||||
share_by_asset_id = {str(item.get("file_asset_id") or ""): item for item in shares}
|
||||
linked_files = [
|
||||
{**item, "share": share_by_asset_id.get(str(item.get("id") or ""))}
|
||||
for item in preview.linkable_files
|
||||
]
|
||||
return CampaignAttachmentLinkMatchesResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
matched_file_count=preview.matched_file_count,
|
||||
already_linked_file_count=preview.linked_file_count,
|
||||
linked_file_count=0 if dry_run else len(file_ids),
|
||||
dry_run=dry_run,
|
||||
linked_files=linked_files,
|
||||
linkable_files=preview.linkable_files,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse)
|
||||
def preview_campaign_attachments(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentPreviewRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
|
||||
payload = payload or CampaignAttachmentPreviewRequest()
|
||||
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
return _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=payload.include_unmatched,
|
||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/versions/{version_id}/attachments/link-matches", response_model=CampaignAttachmentLinkMatchesResponse)
|
||||
def link_campaign_attachment_matches(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentLinkMatchesRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_require_permission(principal, "files:file:share")
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Locked campaign versions cannot link new attachment files")
|
||||
payload = payload or CampaignAttachmentLinkMatchesRequest()
|
||||
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
try:
|
||||
result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked" if not payload.dry_run else "campaign.attachment_matches_link_previewed",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": result.matched_file_count,
|
||||
"already_linked_file_count": result.already_linked_file_count,
|
||||
"linked_file_count": result.linked_file_count,
|
||||
"dry_run": result.dry_run,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
@@ -419,7 +419,19 @@
|
||||
"send_without_attachments": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns."
|
||||
"description": "Legacy compatibility flag. Use send_without_attachments_behavior for new campaigns."
|
||||
},
|
||||
"send_without_attachments_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "continue",
|
||||
"description": "Campaign-wide behavior when no attachment file is resolved for an active message."
|
||||
},
|
||||
"zip": {
|
||||
"$ref": "#/$defs/zip_collection_config",
|
||||
|
||||
@@ -371,6 +371,15 @@ class CampaignSendUnattemptedRequest(BaseModel):
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignSendJobRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
include_warnings: bool = True
|
||||
dry_run: bool = False
|
||||
use_rate_limit: bool = True
|
||||
enqueue_imap_task: bool = False
|
||||
|
||||
|
||||
class CampaignResolveOutcomeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -382,6 +391,7 @@ class ValidateCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
check_files: bool = False
|
||||
link_unshared_matches: bool = False
|
||||
|
||||
|
||||
class BuildCampaignRequest(BaseModel):
|
||||
|
||||
@@ -198,6 +198,22 @@ def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||
|
||||
|
||||
def _reviewed_needs_review_keys(version: CampaignVersion) -> set[str]:
|
||||
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "")
|
||||
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
review_state = editor_state.get("review_send") if isinstance(editor_state.get("review_send"), dict) else {}
|
||||
if not build_token or str(review_state.get("build_token") or "") != build_token:
|
||||
return set()
|
||||
if review_state.get("inspection_complete") is not True:
|
||||
return set()
|
||||
return {str(value) for value in (review_state.get("reviewed_message_keys") or []) if str(value).strip()}
|
||||
|
||||
|
||||
def _job_review_key(job: CampaignJob) -> str:
|
||||
return str(job.entry_id or job.entry_index)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -342,6 +358,7 @@ def queue_campaign_jobs(
|
||||
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
|
||||
|
||||
queued: list[CampaignJob] = []
|
||||
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
||||
skipped_count = 0
|
||||
blocked_count = 0
|
||||
for job in jobs:
|
||||
@@ -360,7 +377,11 @@ def queue_campaign_jobs(
|
||||
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}:
|
||||
skipped_count += 1
|
||||
continue
|
||||
if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation:
|
||||
validation_allowed = job.validation_status in allowed_validation or (
|
||||
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||
and _job_review_key(job) in reviewed_needs_review_keys
|
||||
)
|
||||
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
||||
blocked_count += 1
|
||||
continue
|
||||
if not job.eml_local_path and not job.eml_storage_key:
|
||||
@@ -778,6 +799,143 @@ def queue_unattempted_jobs(
|
||||
}
|
||||
|
||||
|
||||
def send_single_campaign_job(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
include_warnings: bool = True,
|
||||
dry_run: bool = False,
|
||||
use_rate_limit: bool = True,
|
||||
enqueue_imap_task: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Explicitly queue and send one built recipient message through the audit send path."""
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id:
|
||||
raise QueueingError("Campaign job not found or not accessible")
|
||||
version = _get_current_version(session, campaign, version_id=job.campaign_version_id)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
ensure_execution_snapshot(session, version)
|
||||
queue_action = _prepare_single_job_for_send(
|
||||
session,
|
||||
version=version,
|
||||
job=job,
|
||||
include_warnings=include_warnings,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if dry_run:
|
||||
context = _send_job_delivery_context(session, job)
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"job_id": job.id,
|
||||
"action": "single_send",
|
||||
"queue_action": queue_action,
|
||||
"dry_run": True,
|
||||
"result": SendJobResult(
|
||||
job_id=job.id,
|
||||
status="dry_run",
|
||||
attempt_number=job.attempt_count,
|
||||
dry_run=True,
|
||||
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
|
||||
).as_dict(),
|
||||
}
|
||||
|
||||
if queue_action == "queued":
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
if previous_status != campaign.status:
|
||||
_emit_campaign_status_notification(
|
||||
session,
|
||||
campaign=campaign,
|
||||
status=campaign.status,
|
||||
previous_status=previous_status,
|
||||
version_id=version.id,
|
||||
)
|
||||
|
||||
result = send_campaign_job(
|
||||
session,
|
||||
job_id=job.id,
|
||||
dry_run=False,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"job_id": job.id,
|
||||
"action": "single_send",
|
||||
"queue_action": queue_action,
|
||||
"dry_run": False,
|
||||
"result": result.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _prepare_single_job_for_send(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
job: CampaignJob,
|
||||
include_warnings: bool,
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
if job.queue_status == JobQueueStatus.QUEUED.value and job.send_status == JobSendStatus.QUEUED.value:
|
||||
return "already_queued"
|
||||
if job.send_status in SMTP_ACCEPTED_STATUSES:
|
||||
raise QueueingError("This message has already been accepted by SMTP and cannot be sent again from preview.")
|
||||
if job.send_status in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value, JobSendStatus.OUTCOME_UNKNOWN.value}:
|
||||
raise QueueingError(f"This message is in delivery state {job.send_status}; reconcile or wait before sending it again.")
|
||||
if job.send_status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}:
|
||||
raise QueueingError("This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol.")
|
||||
if job.attempt_count > 0:
|
||||
raise QueueingError("This message already has SMTP attempts. Use retry or reconciliation instead of preview send.")
|
||||
if job.build_status != JobBuildStatus.BUILT.value:
|
||||
raise QueueingError("This message has not been built yet.")
|
||||
if not _single_job_validation_allowed(version, job, include_warnings=include_warnings):
|
||||
raise QueueingError(f"This message cannot be sent while validation status is {job.validation_status}.")
|
||||
if not job.eml_local_path and not job.eml_storage_key:
|
||||
raise QueueingError("This message has no generated EML evidence. Rebuild the campaign before sending.")
|
||||
if job.queue_status not in {JobQueueStatus.DRAFT.value, JobQueueStatus.CANCELLED.value}:
|
||||
raise QueueingError(f"This message cannot be sent from queue state {job.queue_status}.")
|
||||
if dry_run:
|
||||
return "would_queue"
|
||||
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
job.queued_at = _utcnow()
|
||||
job.claimed_at = None
|
||||
job.claim_token = None
|
||||
job.smtp_started_at = None
|
||||
job.outcome_unknown_at = None
|
||||
job.last_error = None
|
||||
session.add(job)
|
||||
return "queued"
|
||||
|
||||
|
||||
def _single_job_validation_allowed(
|
||||
version: CampaignVersion,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
include_warnings: bool,
|
||||
) -> bool:
|
||||
allowed = {JobValidationStatus.READY.value}
|
||||
if include_warnings:
|
||||
allowed.add(JobValidationStatus.WARNING.value)
|
||||
if job.validation_status in allowed:
|
||||
return True
|
||||
return (
|
||||
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||
and _job_review_key(job) in _reviewed_needs_review_keys(version)
|
||||
)
|
||||
|
||||
|
||||
def reconcile_job_outcome(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -1168,7 +1326,7 @@ def _preflight_send_campaign_job(
|
||||
)
|
||||
if job.send_status == JobSendStatus.CLAIMED.value:
|
||||
return SendJobResult(
|
||||
job_id=job_id,
|
||||
job_id=job.id,
|
||||
status="already_claimed",
|
||||
attempt_number=job.attempt_count,
|
||||
dry_run=dry_run,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
import secrets
|
||||
import stat
|
||||
import struct
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
import zlib
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
@@ -10,6 +16,8 @@ except ImportError: # pragma: no cover
|
||||
pyzipper = None
|
||||
|
||||
ArchiveMember = tuple[Path, str]
|
||||
ZIP_METHOD_AES = "aes"
|
||||
ZIP_METHOD_STANDARD = "zip_standard"
|
||||
|
||||
|
||||
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
||||
@@ -34,23 +42,17 @@ def create_zip_archive(
|
||||
output_path: Path,
|
||||
files: Iterable[Path | ArchiveMember],
|
||||
password: str = "",
|
||||
method: str = ZIP_METHOD_AES,
|
||||
) -> Path:
|
||||
"""Create a ZIP archive, using AES encryption when a password is supplied."""
|
||||
"""Create a ZIP archive, optionally using AES or legacy ZipCrypto encryption."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
members = _normalized_members(files)
|
||||
if password:
|
||||
if pyzipper is None:
|
||||
raise RuntimeError("pyzipper is required for writing password-protected ZIP files")
|
||||
with pyzipper.AESZipFile(
|
||||
output_path,
|
||||
"w",
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES,
|
||||
) as zip_file:
|
||||
zip_file.setpassword(password.encode("utf-8"))
|
||||
for file_path, archive_name in members:
|
||||
zip_file.write(file_path, arcname=archive_name)
|
||||
if method == ZIP_METHOD_STANDARD:
|
||||
_create_zipcrypto_archive(output_path, members, password)
|
||||
return output_path
|
||||
_create_aes_archive(output_path, members, password)
|
||||
return output_path
|
||||
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
|
||||
@@ -59,7 +61,214 @@ def create_zip_archive(
|
||||
return output_path
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||
|
||||
return create_zip_archive(output_path, files, password)
|
||||
return create_zip_archive(output_path, files, password, method)
|
||||
|
||||
|
||||
def _create_aes_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
|
||||
if pyzipper is None:
|
||||
raise RuntimeError("pyzipper is required for writing AES password-protected ZIP files")
|
||||
with pyzipper.AESZipFile(
|
||||
output_path,
|
||||
"w",
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES,
|
||||
) as zip_file:
|
||||
zip_file.setpassword(password.encode("utf-8"))
|
||||
for file_path, archive_name in members:
|
||||
zip_file.write(file_path, arcname=archive_name)
|
||||
|
||||
|
||||
def _create_zipcrypto_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
|
||||
entries: list[_CentralDirectoryEntry] = []
|
||||
password_bytes = password.encode("utf-8")
|
||||
with output_path.open("wb") as zip_file:
|
||||
for file_path, archive_name in members:
|
||||
local_header_offset = zip_file.tell()
|
||||
file_data = file_path.read_bytes()
|
||||
crc = binascii.crc32(file_data) & 0xFFFFFFFF
|
||||
compressed_data = _deflate(file_data)
|
||||
encrypted_data = _zipcrypto_encrypt(compressed_data, password_bytes, crc)
|
||||
compressed_size = len(encrypted_data)
|
||||
uncompressed_size = len(file_data)
|
||||
modified_at = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||
dos_time, dos_date = _dos_timestamp(modified_at)
|
||||
filename_bytes, flags = _filename_bytes(archive_name, encrypted=True)
|
||||
|
||||
zip_file.write(
|
||||
struct.pack(
|
||||
"<IHHHHHIIIHH",
|
||||
0x04034B50,
|
||||
20,
|
||||
flags,
|
||||
zipfile.ZIP_DEFLATED,
|
||||
dos_time,
|
||||
dos_date,
|
||||
crc,
|
||||
compressed_size,
|
||||
uncompressed_size,
|
||||
len(filename_bytes),
|
||||
0,
|
||||
)
|
||||
)
|
||||
zip_file.write(filename_bytes)
|
||||
zip_file.write(encrypted_data)
|
||||
entries.append(
|
||||
_CentralDirectoryEntry(
|
||||
filename_bytes=filename_bytes,
|
||||
flags=flags,
|
||||
dos_time=dos_time,
|
||||
dos_date=dos_date,
|
||||
crc=crc,
|
||||
compressed_size=compressed_size,
|
||||
uncompressed_size=uncompressed_size,
|
||||
external_attributes=_external_attributes(file_path),
|
||||
local_header_offset=local_header_offset,
|
||||
)
|
||||
)
|
||||
|
||||
central_directory_offset = zip_file.tell()
|
||||
for entry in entries:
|
||||
zip_file.write(
|
||||
struct.pack(
|
||||
"<IHHHHHHIIIHHHHHII",
|
||||
0x02014B50,
|
||||
20,
|
||||
20,
|
||||
entry.flags,
|
||||
zipfile.ZIP_DEFLATED,
|
||||
entry.dos_time,
|
||||
entry.dos_date,
|
||||
entry.crc,
|
||||
entry.compressed_size,
|
||||
entry.uncompressed_size,
|
||||
len(entry.filename_bytes),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
entry.external_attributes,
|
||||
entry.local_header_offset,
|
||||
)
|
||||
)
|
||||
zip_file.write(entry.filename_bytes)
|
||||
|
||||
central_directory_size = zip_file.tell() - central_directory_offset
|
||||
zip_file.write(
|
||||
struct.pack(
|
||||
"<IHHHHIIH",
|
||||
0x06054B50,
|
||||
0,
|
||||
0,
|
||||
len(entries),
|
||||
len(entries),
|
||||
central_directory_size,
|
||||
central_directory_offset,
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _CentralDirectoryEntry:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
filename_bytes: bytes,
|
||||
flags: int,
|
||||
dos_time: int,
|
||||
dos_date: int,
|
||||
crc: int,
|
||||
compressed_size: int,
|
||||
uncompressed_size: int,
|
||||
external_attributes: int,
|
||||
local_header_offset: int,
|
||||
) -> None:
|
||||
self.filename_bytes = filename_bytes
|
||||
self.flags = flags
|
||||
self.dos_time = dos_time
|
||||
self.dos_date = dos_date
|
||||
self.crc = crc
|
||||
self.compressed_size = compressed_size
|
||||
self.uncompressed_size = uncompressed_size
|
||||
self.external_attributes = external_attributes
|
||||
self.local_header_offset = local_header_offset
|
||||
|
||||
|
||||
def _deflate(data: bytes) -> bytes:
|
||||
compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
|
||||
return compressor.compress(data) + compressor.flush()
|
||||
|
||||
|
||||
def _zipcrypto_encrypt(data: bytes, password: bytes, crc: int) -> bytes:
|
||||
cipher = _ZipCrypto(password)
|
||||
header = secrets.token_bytes(11) + bytes([(crc >> 24) & 0xFF])
|
||||
return cipher.encrypt(header + data)
|
||||
|
||||
|
||||
class _ZipCrypto:
|
||||
def __init__(self, password: bytes) -> None:
|
||||
self.key0 = 0x12345678
|
||||
self.key1 = 0x23456789
|
||||
self.key2 = 0x34567890
|
||||
for value in password:
|
||||
self._update_keys(value)
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
encrypted = bytearray()
|
||||
for value in data:
|
||||
encrypted.append(value ^ self._decrypt_byte())
|
||||
self._update_keys(value)
|
||||
return bytes(encrypted)
|
||||
|
||||
def _decrypt_byte(self) -> int:
|
||||
temp = self.key2 | 2
|
||||
return ((temp * (temp ^ 1)) >> 8) & 0xFF
|
||||
|
||||
def _update_keys(self, value: int) -> None:
|
||||
self.key0 = _zipcrypto_crc32_byte(value, self.key0)
|
||||
self.key1 = (self.key1 + (self.key0 & 0xFF)) & 0xFFFFFFFF
|
||||
self.key1 = (self.key1 * 134775813 + 1) & 0xFFFFFFFF
|
||||
self.key2 = _zipcrypto_crc32_byte((self.key1 >> 24) & 0xFF, self.key2)
|
||||
|
||||
|
||||
def _build_zipcrypto_crc(value: int) -> int:
|
||||
for _ in range(8):
|
||||
if value & 1:
|
||||
value = 0xEDB88320 ^ (value >> 1)
|
||||
else:
|
||||
value >>= 1
|
||||
return value
|
||||
|
||||
|
||||
_ZIPCRYPTO_CRC_TABLE = [_build_zipcrypto_crc(value) for value in range(256)]
|
||||
|
||||
|
||||
def _zipcrypto_crc32_byte(value: int, crc: int) -> int:
|
||||
return ((crc >> 8) ^ _ZIPCRYPTO_CRC_TABLE[(crc ^ value) & 0xFF]) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _filename_bytes(filename: str, *, encrypted: bool) -> tuple[bytes, int]:
|
||||
flags = 0x1 if encrypted else 0
|
||||
try:
|
||||
return filename.encode("ascii"), flags
|
||||
except UnicodeEncodeError:
|
||||
return filename.encode("utf-8"), flags | 0x800
|
||||
|
||||
|
||||
def _dos_timestamp(value: datetime) -> tuple[int, int]:
|
||||
year = min(max(value.year, 1980), 2107)
|
||||
month = value.month if value.year == year else 1
|
||||
day = value.day if value.year == year else 1
|
||||
dos_time = value.hour << 11 | value.minute << 5 | value.second // 2
|
||||
dos_date = (year - 1980) << 9 | month << 5 | day
|
||||
return dos_time, dos_date
|
||||
|
||||
|
||||
def _external_attributes(path: Path) -> int:
|
||||
try:
|
||||
permissions = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError:
|
||||
return 0
|
||||
return permissions << 16
|
||||
|
||||
206
tests/test_attachment_building.py
Normal file
206
tests/test_attachment_building.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
|
||||
|
||||
class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
def _no_attachment_config(
|
||||
self,
|
||||
behavior: str | None = None,
|
||||
legacy_allow: bool | None = None,
|
||||
*,
|
||||
configure_missing_rule: bool = False,
|
||||
) -> CampaignConfig:
|
||||
attachments: dict[str, object] = {
|
||||
"base_path": ".",
|
||||
"global": [],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
}
|
||||
if configure_missing_rule:
|
||||
attachments["global"] = [
|
||||
{
|
||||
"id": "missing",
|
||||
"label": "Missing attachment",
|
||||
"base_dir": ".",
|
||||
"file_filter": "missing.pdf",
|
||||
"required": False,
|
||||
"missing_behavior": "continue",
|
||||
}
|
||||
]
|
||||
if behavior is not None:
|
||||
attachments["send_without_attachments_behavior"] = behavior
|
||||
if legacy_allow is not None:
|
||||
attachments["send_without_attachments"] = legacy_allow
|
||||
return CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||
"template": {"subject": "Subject", "text": "Body"},
|
||||
"attachments": attachments,
|
||||
"entries": {"inline": [{"id": "recipient-1", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
||||
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(legacy_allow=False)
|
||||
|
||||
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||
self.assertTrue(validation.ok)
|
||||
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
|
||||
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||
self.assertEqual(result.report.build_failed_count, 0)
|
||||
self.assertEqual(result.report.queueable_count, 1)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.attachment_count, 0)
|
||||
self.assertIsNotNone(message.eml_path)
|
||||
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in message.issues})
|
||||
self.assertIsNotNone(result.built_messages[0].mime)
|
||||
|
||||
def test_configured_attachment_rule_blocks_generation_when_no_files_resolve(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(behavior="block", configure_missing_rule=True)
|
||||
|
||||
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||
self.assertFalse(validation.ok)
|
||||
self.assertIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
|
||||
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||
self.assertEqual(result.report.build_failed_count, 1)
|
||||
self.assertEqual(result.report.queueable_count, 0)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.attachment_count, 0)
|
||||
self.assertIsNone(message.eml_path)
|
||||
self.assertIn("missing_attachment_coverage", {issue.code for issue in message.issues})
|
||||
self.assertIsNone(result.built_messages[0].mime)
|
||||
|
||||
def test_send_without_attachments_behavior_modes(self) -> None:
|
||||
cases = {
|
||||
"block": ("build_failed", "blocked", 0, "block", False),
|
||||
"ask": ("built", "needs_review", 0, "ask", True),
|
||||
"drop": ("built", "excluded", 0, "drop", True),
|
||||
"warn": ("built", "warning", 1, "warn", True),
|
||||
"continue": ("built", "ready", 1, None, True),
|
||||
}
|
||||
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
||||
with self.subTest(behavior=behavior):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(behavior=behavior, configure_missing_rule=True)
|
||||
|
||||
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||
if behavior == "block":
|
||||
self.assertFalse(validation.ok)
|
||||
else:
|
||||
self.assertTrue(validation.ok)
|
||||
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||
self.assertEqual(result.report.queueable_count, queueable_count)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.build_status.value, build_status)
|
||||
self.assertEqual(message.validation_status.value, validation_status)
|
||||
coverage_issues = [issue for issue in message.issues if issue.code == "missing_attachment_coverage"]
|
||||
if issue_behavior is None:
|
||||
self.assertEqual(coverage_issues, [])
|
||||
else:
|
||||
self.assertEqual(coverage_issues[0].behavior, issue_behavior)
|
||||
self.assertEqual(result.built_messages[0].mime is not None, has_mime)
|
||||
|
||||
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
documents = root / "documents"
|
||||
documents.mkdir()
|
||||
(documents / "matched.xlsx").write_bytes(b"matched workbook")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||
"template": {"subject": "Subject", "text": "Body"},
|
||||
"attachments": {
|
||||
"base_path": ".",
|
||||
"allow_individual": True,
|
||||
"send_without_attachments": False,
|
||||
"zip": {
|
||||
"enabled": True,
|
||||
"archives": [{"id": "bundle", "name": "recipient-bundle.zip", "standard": True}],
|
||||
},
|
||||
"global": [],
|
||||
},
|
||||
"entries": {
|
||||
"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
"attachments": [
|
||||
{"id": "matched", "base_dir": "documents", "file_filter": "matched.xlsx", "required": True},
|
||||
{
|
||||
"id": "missing",
|
||||
"base_dir": "documents",
|
||||
"file_filter": "missing.xlsx",
|
||||
"required": False,
|
||||
"missing_behavior": "warn",
|
||||
},
|
||||
],
|
||||
}]
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"missing_optional_attachment": "warn",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||
self.assertTrue(validation.ok)
|
||||
self.assertIn("missing_optional_attachment", {issue.code for issue in validation.issues})
|
||||
|
||||
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=False)
|
||||
self.assertEqual(result.report.built_count, 1)
|
||||
self.assertEqual(result.report.queueable_count, 1)
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.attachment_count, 1)
|
||||
summaries = {attachment.attachment_id: attachment for attachment in message.attachments}
|
||||
self.assertEqual(summaries["matched"].zip_filename, "recipient-bundle.zip")
|
||||
self.assertEqual(summaries["matched"].zip_entry_names, ["matched.xlsx"])
|
||||
self.assertIsNone(summaries["missing"].zip_filename)
|
||||
self.assertEqual(summaries["missing"].zip_entry_names, [])
|
||||
self.assertEqual(summaries["missing"].matches, [])
|
||||
|
||||
mime = result.built_messages[0].mime
|
||||
self.assertIsNotNone(mime)
|
||||
attachments = {part.get_filename(): part.get_payload(decode=True) for part in mime.iter_attachments()}
|
||||
self.assertEqual(set(attachments), {"recipient-bundle.zip"})
|
||||
with zipfile.ZipFile(io.BytesIO(attachments["recipient-bundle.zip"])) as archive:
|
||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
68
tests/test_zip_service.py
Normal file
68
tests/test_zip_service.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
pyzipper = None
|
||||
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
|
||||
|
||||
class ZipServiceTests(unittest.TestCase):
|
||||
def test_standard_password_zip_uses_zipcrypto_readable_by_stdlib(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "source.txt"
|
||||
source.write_text("Hello Windows ZIP", encoding="utf-8")
|
||||
output = root / "standard.zip"
|
||||
|
||||
create_zip_archive(output, [(source, "message.txt")], "secret", "zip_standard")
|
||||
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
info = archive.getinfo("message.txt")
|
||||
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||
self.assertTrue(info.flag_bits & 0x1)
|
||||
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
||||
|
||||
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
||||
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "source.txt"
|
||||
source.write_text("Hello AES ZIP", encoding="utf-8")
|
||||
output = root / "aes.zip"
|
||||
|
||||
create_zip_archive(output, [(source, "message.txt")], "secret", "aes")
|
||||
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
info = archive.getinfo("message.txt")
|
||||
self.assertEqual(info.compress_type, 99)
|
||||
self.assertTrue(info.flag_bits & 0x1)
|
||||
|
||||
with pyzipper.AESZipFile(output) as archive:
|
||||
archive.setpassword(b"secret")
|
||||
self.assertEqual(archive.read("message.txt"), b"Hello AES ZIP")
|
||||
|
||||
def test_unprotected_zip_ignores_encryption_mode(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
source = root / "source.txt"
|
||||
source.write_text("Plain ZIP", encoding="utf-8")
|
||||
output = root / "plain.zip"
|
||||
|
||||
create_zip_archive(output, [(source, "message.txt")], "", "zip_standard")
|
||||
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
info = archive.getinfo("message.txt")
|
||||
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||
self.assertFalse(info.flag_bits & 0x1)
|
||||
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,7 +26,8 @@
|
||||
"scripts": {
|
||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
||||
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js"
|
||||
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
||||
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -313,6 +313,8 @@ export type CampaignAttachmentPreviewFile = {
|
||||
checksum_sha256?: string;
|
||||
size_bytes?: number;
|
||||
content_type?: string | null;
|
||||
linked_to_campaign?: boolean;
|
||||
share?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CampaignAttachmentPreviewRule = {
|
||||
@@ -334,6 +336,8 @@ export type CampaignAttachmentPreviewRule = {
|
||||
zip_filename?: string | null;
|
||||
matches: CampaignAttachmentPreviewFile[];
|
||||
match_count: number;
|
||||
linked_match_count?: number;
|
||||
unlinked_match_count?: number;
|
||||
issues: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
@@ -341,15 +345,37 @@ export type CampaignAttachmentPreviewResponse = {
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
shared_file_count: number;
|
||||
candidate_file_count?: number;
|
||||
matched_file_count?: number;
|
||||
linked_file_count?: number;
|
||||
unlinked_file_count?: number;
|
||||
rules: CampaignAttachmentPreviewRule[];
|
||||
linkable_files?: CampaignAttachmentPreviewFile[];
|
||||
unused_shared_files: CampaignAttachmentPreviewFile[];
|
||||
};
|
||||
|
||||
export type CampaignAttachmentPreviewPayload = {
|
||||
include_unmatched?: boolean;
|
||||
include_unlinked_candidates?: boolean;
|
||||
campaign_json?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignAttachmentLinkMatchesPayload = {
|
||||
campaign_json?: Record<string, unknown> | null;
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
export type CampaignAttachmentLinkMatchesResponse = {
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
matched_file_count: number;
|
||||
already_linked_file_count: number;
|
||||
linked_file_count: number;
|
||||
dry_run?: boolean;
|
||||
linked_files: CampaignAttachmentPreviewFile[];
|
||||
linkable_files: CampaignAttachmentPreviewFile[];
|
||||
};
|
||||
|
||||
export type CampaignMockSendPayload = {
|
||||
version_id?: string | null;
|
||||
send?: boolean;
|
||||
@@ -365,6 +391,13 @@ export type CampaignReviewStatePayload = {
|
||||
reviewed_message_keys: string[];
|
||||
};
|
||||
|
||||
export type CampaignSendJobPayload = {
|
||||
include_warnings?: boolean;
|
||||
dry_run?: boolean;
|
||||
use_rate_limit?: boolean;
|
||||
enqueue_imap_task?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type CampaignJobsQuery = {
|
||||
versionId?: string;
|
||||
@@ -702,11 +735,12 @@ versionId: string)
|
||||
export async function validateVersion(
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
checkFiles = false)
|
||||
checkFiles = false,
|
||||
linkUnsharedMatches = false)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ check_files: checkFiles })
|
||||
body: JSON.stringify({ check_files: checkFiles, link_unshared_matches: linkUnsharedMatches })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -735,6 +769,19 @@ payload: CampaignAttachmentPreviewPayload = {})
|
||||
);
|
||||
}
|
||||
|
||||
export function linkCampaignAttachmentMatches(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignAttachmentLinkMatchesPayload = {})
|
||||
: Promise<CampaignAttachmentLinkMatchesResponse> {
|
||||
return apiFetch<CampaignAttachmentLinkMatchesResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/link-matches`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCampaignSummary(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -846,6 +893,18 @@ payload: Record<string, unknown> = {})
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendCampaignJob(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
payload: CampaignSendJobPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveCampaignJobOutcome(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
@@ -40,7 +41,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -238,7 +239,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,6 +250,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
<>
|
||||
<div className="metric-grid attachment-statistics-grid">
|
||||
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
|
||||
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||
<div className="admin-table-surface attachment-sources-table-surface">
|
||||
<DataGrid
|
||||
@@ -300,7 +308,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
{zipArchiveNameValidation.message &&
|
||||
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
||||
}
|
||||
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||
@@ -319,20 +326,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
|
||||
</dl>
|
||||
<p className="muted small-note">{managedFilesAvailable ?
|
||||
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
|
||||
filesModuleInstalled ?
|
||||
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
|
||||
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
@@ -450,6 +443,19 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
|
||||
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||
},
|
||||
{
|
||||
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
|
||||
render: (archive, index) =>
|
||||
<ToggleSwitch
|
||||
label="Win-compatible"
|
||||
checked={archive.method === "zip_standard"}
|
||||
disabled={disabled}
|
||||
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
|
||||
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
|
||||
|
||||
value: (archive) => archive.method
|
||||
},
|
||||
{
|
||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,4 +32,4 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -157,7 +157,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,4 +36,4 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void load(null)} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={create} disabled={creating}>
|
||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||
</Button>
|
||||
|
||||
@@ -131,6 +131,19 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
}
|
||||
}
|
||||
|
||||
async function discardOverview() {
|
||||
if (campaign) {
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
}
|
||||
await reload({ force: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -139,8 +152,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
|
||||
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,13 +161,6 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
||||
<div className="metric-grid campaign-overview-metrics current-version-metrics">
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div className="metric-grid campaign-overview-metrics">
|
||||
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
||||
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
||||
@@ -163,23 +168,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
||||
</div>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile 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"])} />
|
||||
<SummaryTile 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 ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
|
||||
<Card
|
||||
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
|
||||
collapsible
|
||||
actions={<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>}>
|
||||
<div className="form-grid campaign-identity-grid">
|
||||
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||
@@ -198,8 +190,27 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
|
||||
<div className="admin-table-surface">
|
||||
<Card title="Versions" collapsible actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<div className="metric-grid inside campaign-versions-metrics">
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
<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} />
|
||||
</div>
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile 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"])} />
|
||||
<SummaryTile 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 ?? "—"} />
|
||||
</div>
|
||||
<div className="admin-table-surface version-history-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
rows={versions}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getCampaignJobsDelta,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendCampaignJob,
|
||||
sendUnattemptedCampaignJobs,
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse } from
|
||||
@@ -155,7 +156,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
}, [loadJobs]);
|
||||
|
||||
async function reloadAll() {
|
||||
await Promise.all([reload(), loadJobs()]);
|
||||
resetDeltaWatermark(jobsQueryKey);
|
||||
jobPageCursorsRef.current = { 1: null };
|
||||
jobsRef.current = emptyCampaignJobsResponse();
|
||||
setJobs(emptyCampaignJobsResponse());
|
||||
await Promise.all([reload({ force: true }), loadJobs()]);
|
||||
}
|
||||
|
||||
async function runExplicitAction(action: "retry" | "unattempted") {
|
||||
@@ -177,6 +182,67 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
}
|
||||
}
|
||||
|
||||
const failedRowsOnPage = useMemo(
|
||||
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
|
||||
[jobs.jobs]
|
||||
);
|
||||
|
||||
async function retryFailedSynchronously(rows: Record<string, unknown>[]) {
|
||||
if (!version || busyAction || rows.length === 0) return;
|
||||
setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page");
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
let attempted = 0;
|
||||
let accepted = 0;
|
||||
let skipped = 0;
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "");
|
||||
const queueResponse = await retryCampaignJobs(settings, campaignId, {
|
||||
version_id: version.id,
|
||||
job_ids: [jobId],
|
||||
include_permanent: sendStatus === "failed_permanent",
|
||||
enqueue_celery: false
|
||||
});
|
||||
const queueResult = asRecord(queueResponse.result ?? queueResponse);
|
||||
if (Number(queueResult.selected_count ?? 0) < 1) {
|
||||
skipped += 1;
|
||||
const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : [];
|
||||
const reason = String(skippedRows[0]?.reason ?? "not selected for retry");
|
||||
failures.push(`${shortJobId(jobId)}: ${reason}`);
|
||||
continue;
|
||||
}
|
||||
attempted += 1;
|
||||
try {
|
||||
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
|
||||
include_warnings: true,
|
||||
dry_run: false,
|
||||
use_rate_limit: true,
|
||||
enqueue_imap_task: false
|
||||
});
|
||||
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
||||
const status = String(sendResult.status ?? "submitted");
|
||||
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
|
||||
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
|
||||
} catch (err) {
|
||||
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
const failed = failures.length;
|
||||
setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`);
|
||||
if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n"));
|
||||
await reloadAll();
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileOutcome() {
|
||||
if (!reconcile || busyAction) return;
|
||||
setBusyAction("reconcile");
|
||||
@@ -300,13 +366,14 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}],
|
||||
[busyAction]);
|
||||
[busyAction, retryFailedSynchronously]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||
@@ -350,6 +417,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
||||
</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent";
|
||||
}
|
||||
|
||||
function shortJobId(jobId: string): string {
|
||||
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||
import CampaignFieldsPage from "./CampaignFieldsPage";
|
||||
import GlobalSettingsPage from "./GlobalSettingsPage";
|
||||
import RecipientDataPage from "./RecipientDataPage";
|
||||
import RecipientDetailsPage from "./RecipientDetailsPage";
|
||||
import TemplateDataPage from "./TemplateDataPage";
|
||||
import AttachmentsDataPage from "./AttachmentsDataPage";
|
||||
import MailSettingsPage from "./MailSettingsPage";
|
||||
@@ -26,7 +25,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
policies: "policies",
|
||||
fields: "fields",
|
||||
recipients: "recipients",
|
||||
"recipient-data": "recipient-data",
|
||||
"recipient-data": "recipients",
|
||||
template: "template",
|
||||
files: "files",
|
||||
"mail-settings": "mail-settings",
|
||||
@@ -85,7 +84,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
||||
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||
@@ -127,7 +126,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
if (section === "policies" || section === "policy") return "policies";
|
||||
if (section === "fields") return "fields";
|
||||
if (section === "recipients") return "recipients";
|
||||
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
|
||||
if (section === "recipient-data" || section === "recipient-details") return "recipients";
|
||||
if (section === "template") return "template";
|
||||
if (section === "files" || section === "attachments") return "files";
|
||||
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -71,6 +71,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function patchSendWithoutAttachmentsBehavior(value: string) {
|
||||
patch(["attachments", "send_without_attachments_behavior"], value);
|
||||
patch(["attachments", "send_without_attachments"], value !== "block");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -79,7 +84,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,8 +213,8 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
effectiveClassName="campaign-policy-effective-note"
|
||||
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
|
||||
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
|
||||
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
|
||||
control={<PolicySelectControl value={sendWithoutAttachmentsBehavior(attachments)} disabled={locked} onChange={patchSendWithoutAttachmentsBehavior} />}
|
||||
effective={behaviorSummary(sendWithoutAttachmentsBehavior(attachments))} />
|
||||
|
||||
</PolicyTable>
|
||||
</Card>
|
||||
@@ -261,3 +266,9 @@ function behaviorSummary(value: string): string {
|
||||
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
||||
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
||||
}
|
||||
|
||||
function sendWithoutAttachmentsBehavior(attachments: Record<string, unknown>): string {
|
||||
const configured = getText(attachments, "send_without_attachments_behavior");
|
||||
if (behaviorOptions.includes(configured)) return configured;
|
||||
return getBool(attachments, "send_without_attachments", true) ? "continue" : "block";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { MailServerSettingsPanel, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||
import { MailServerFolderLookupResultView, MailServerSettingsPanel, ToggleSwitch, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
@@ -59,7 +59,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -98,6 +98,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
||||
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const imapAppendFolder = getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"));
|
||||
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
||||
const inlinePolicyMessages = useMemo(() => {
|
||||
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
||||
@@ -393,7 +394,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,6 +465,33 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
</Card>
|
||||
}
|
||||
|
||||
{!isPolicyView &&
|
||||
<Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
<ToggleSwitch
|
||||
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
||||
checked={imapAppendEnabled}
|
||||
disabled={imapDisabled}
|
||||
onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||
</div>
|
||||
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help="i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9">
|
||||
<div className="field-with-action mail-server-folder-field">
|
||||
<input
|
||||
value={imapAppendFolder}
|
||||
disabled={appendTargetFolderDisabled}
|
||||
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)}
|
||||
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
||||
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={appendTargetFolderDisabled || mailActionState === "folders"}>
|
||||
{mailActionState === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : "i18n:govoplan-core.folders.c603ab65"}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<MailServerFolderLookupResultView result={folderResult} disabled={appendTargetFolderDisabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
|
||||
{!isPolicyView &&
|
||||
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
||||
{inlinePolicyMessages.length > 0 &&
|
||||
@@ -489,25 +517,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
imapCredentialDisabled={imapCredentialDisabled}
|
||||
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
||||
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
||||
append={{
|
||||
enabled: imapAppendEnabled,
|
||||
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
|
||||
disabled: imapDisabled,
|
||||
folderDisabled: appendTargetFolderDisabled,
|
||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
||||
}}
|
||||
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
||||
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={runSmtpTest}
|
||||
onTestImap={runImapTest}
|
||||
onLookupFolders={runFolderLookup}
|
||||
smtpTestResult={smtpTestResult}
|
||||
imapTestResult={imapTestResult}
|
||||
folderLookupResult={folderResult}
|
||||
onUseDetectedFolder={useDetectedSentFolder}
|
||||
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
||||
floatingResults />
|
||||
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { RecipientImportDialog } from "./RecipientDataPage";
|
||||
import { addressesFromValue } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
|
||||
export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [recipientDataPage, setRecipientDataPage] = useState(1);
|
||||
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "recipient-data",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
|
||||
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
|
||||
});
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
||||
const source = asRecord(entries.source);
|
||||
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
||||
const attachmentSection = asRecord(displayDraft.attachments);
|
||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
||||
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
||||
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
|
||||
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
|
||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
|
||||
|
||||
|
||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
||||
patch(["entries", "inline"], nextEntries);
|
||||
}
|
||||
|
||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
fields: {
|
||||
...asRecord(entry.fields),
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
|
||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
||||
}
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
||||
if (locked || !draft) return;
|
||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode }));
|
||||
markDirty();
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<CampaignDraftPageScaffold
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
|
||||
loading={loading}
|
||||
version={version}
|
||||
versions={data.versions}
|
||||
saveState={saveState}
|
||||
onReload={reload}
|
||||
onSave={() => saveDraft("manual")}
|
||||
dirty={dirty}
|
||||
locked={locked}
|
||||
draft={draft}
|
||||
error={error}
|
||||
localError={localError}
|
||||
currentVersionId={data.campaign?.current_version_id}>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
||||
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
|
||||
}
|
||||
{inlineEntries.length > 0 &&
|
||||
<div className="admin-table-surface recipient-data-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries}
|
||||
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||
getRowKey={(entry, index) => String(entry.id || index)}
|
||||
emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
|
||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||
pagination={{
|
||||
page: recipientDataPage,
|
||||
pageSize: recipientDataPageSize,
|
||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||
onPageChange: setRecipientDataPage,
|
||||
onPageSizeChange: (pageSize) => {
|
||||
setRecipientDataPageSize(pageSize);
|
||||
setRecipientDataPage(1);
|
||||
}
|
||||
}} />
|
||||
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
</CampaignDraftPageScaffold>
|
||||
|
||||
{importOpen &&
|
||||
<RecipientImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
existingEntries={inlineEntries}
|
||||
existingFields={fieldDefinitions}
|
||||
defaultAttachmentBasePath={defaultImportBasePath}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onImport={applyRecipientImport} />
|
||||
|
||||
}
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
type RecipientDataColumnContext = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
draft: Record<string, unknown>;
|
||||
locked: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
zipConfig: AttachmentZipCollection;
|
||||
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||
{
|
||||
id: "recipient",
|
||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (entry) =>
|
||||
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
|
||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
|
||||
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
||||
</Link>,
|
||||
|
||||
value: firstRecipientEmail
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
width: 180,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<AttachmentRulesOverlay
|
||||
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
||||
rules={attachments}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
disabled={locked}
|
||||
buttonLabel={`entries: ${attachments.length}`}
|
||||
basePaths={individualAttachmentBasePaths}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={buildTemplatePreviewContext(draft, entry)}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
||||
|
||||
|
||||
},
|
||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
||||
},
|
||||
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
|
||||
id: `field-${field.name}`,
|
||||
header: field.label || field.name,
|
||||
width: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
|
||||
render: (entry, index) => {
|
||||
const fields = asRecord(entry.fields);
|
||||
return (
|
||||
<FieldValueInput
|
||||
className="recipient-field-input"
|
||||
fieldType={field.type}
|
||||
value={fields[field.name]}
|
||||
disabled={locked || field.can_override === false}
|
||||
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
||||
|
||||
|
||||
},
|
||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
||||
}))];
|
||||
|
||||
}
|
||||
|
||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
|
||||
}
|
||||
|
||||
function extraRecipientCount(entry: Record<string, unknown>): number {
|
||||
const count = addressesFromValue(entry.to).length;
|
||||
return Math.max(0, count - 1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
@@ -121,6 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
const handle = window.setTimeout(() => {
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
include_unlinked_candidates: true,
|
||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||
}).
|
||||
then((response) => {
|
||||
@@ -233,7 +234,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,45 +400,46 @@ draft: Record<string, unknown>)
|
||||
: CampaignMessagePreviewAttachment[] {
|
||||
if (loading) {
|
||||
return [{
|
||||
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
|
||||
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
|
||||
filename: "Resolving attachment patterns",
|
||||
detail: "Managed files are being checked for this recipient preview."
|
||||
}];
|
||||
}
|
||||
if (error) {
|
||||
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
|
||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||
}
|
||||
return rules.flatMap((rule) => {
|
||||
const zipProtection = zipProtectionForRule(rule, draft);
|
||||
const detailParts = [
|
||||
rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
|
||||
rule.source === "global" ? "Global" : "Recipient",
|
||||
rule.label,
|
||||
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
|
||||
rule.required ? "Required" : "Optional",
|
||||
rule.pattern].
|
||||
filter(Boolean);
|
||||
const detail = detailParts.join(" · ");
|
||||
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
|
||||
const fallbackArchiveLabel = "Recipient attachments ZIP";
|
||||
if (rule.matches.length > 0) {
|
||||
return rule.matches.map((match) => ({
|
||||
filename: match.filename || match.display_path,
|
||||
label: rule.label,
|
||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
|
||||
detail: `${match.linked_to_campaign === false ? "Unlinked candidate" : "Linked"} · ${match.display_path ? `${detail} · ${match.display_path}` : detail}`,
|
||||
contentType: match.content_type,
|
||||
sizeBytes: match.size_bytes,
|
||||
linkedToCampaign: match.linked_to_campaign !== false,
|
||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
|
||||
const pattern = rule.pattern || "attachment pattern";
|
||||
return [{
|
||||
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
|
||||
filename: `No file matched ${pattern}`,
|
||||
label: rule.label,
|
||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
|
||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
detail: rule.status !== "ok" ? `${detail} · ${rule.status}` : detail,
|
||||
archiveGroup: null,
|
||||
archiveLabel: null,
|
||||
protected: false,
|
||||
protectionNote: null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function CampaignDraftPageScaffold({
|
||||
<VersionLine version={version} versions={versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onReload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={onReload} disabled={loading}>Discard</Button>
|
||||
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
||||
raw?: string | null;
|
||||
rawLabel?: string;
|
||||
navigation?: CampaignMessagePreviewNavigation;
|
||||
actions?: ReactNode;
|
||||
closeLabel?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export default function CampaignMessagePreviewOverlay({
|
||||
raw,
|
||||
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
||||
navigation,
|
||||
actions,
|
||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||
onClose
|
||||
}: CampaignMessagePreviewOverlayProps) {
|
||||
@@ -131,7 +133,10 @@ export default function CampaignMessagePreviewOverlay({
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||||
<footer className="modal-footer">
|
||||
{actions && <div className="button-row compact-actions">{actions}</div>}
|
||||
<Button variant="primary" onClick={onClose}>{closeLabel}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -146,4 +151,4 @@ function isEditableTarget(target: EventTarget | null): boolean {
|
||||
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
|
||||
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
|
||||
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
|
||||
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;
|
||||
|
||||
@@ -12,7 +12,7 @@ type UseCampaignDraftEditorOptions = {
|
||||
campaignId: string;
|
||||
version: CampaignVersionDetail | null;
|
||||
locked: boolean;
|
||||
reload: () => Promise<void>;
|
||||
reload: (options?: {force?: boolean;}) => Promise<void>;
|
||||
setError: (message: string) => void;
|
||||
currentStep: StepValue;
|
||||
currentFlow?: string;
|
||||
@@ -124,12 +124,25 @@ export function useCampaignDraftEditor({
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||
|
||||
const discardDraft = useCallback(async () => {
|
||||
if (version) {
|
||||
const initialDraft = ensureCampaignDraft(version);
|
||||
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
||||
setDraft(loadedDraft);
|
||||
setDirty(false);
|
||||
setLocalError("");
|
||||
setSaveState(loadedLabelRef.current(version));
|
||||
onLoadedRef.current?.(version, loadedDraft);
|
||||
}
|
||||
await reload({ force: true });
|
||||
}, [reload, version]);
|
||||
|
||||
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
||||
title: unsavedTitle,
|
||||
message: unsavedMessage,
|
||||
onSave: () => saveDraft("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null, [dirty, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
||||
onDiscard: () => { void discardDraft(); }
|
||||
} : null, [dirty, discardDraft, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
||||
|
||||
useRegisterCampaignUnsavedChanges(unsavedRegistration);
|
||||
|
||||
@@ -145,6 +158,7 @@ export function useCampaignDraftEditor({
|
||||
setLocalError,
|
||||
patch,
|
||||
markDirty,
|
||||
discardDraft,
|
||||
saveDraft
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,14 +52,16 @@ export function useCampaignWorkspaceData(
|
||||
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const reload = useCallback(async (options?: {force?: boolean;}) => {
|
||||
if (!campaignId) return;
|
||||
const force = options?.force === true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
let nextWatermark = getDeltaWatermark(queryKey);
|
||||
let merged: CampaignWorkspaceData = dataRef.current;
|
||||
if (force) resetDeltaWatermark(queryKey);
|
||||
let nextWatermark = force ? null : getDeltaWatermark(queryKey);
|
||||
let merged: CampaignWorkspaceData = force ? initialData : dataRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||
|
||||
53
webui/src/features/campaigns/utils/attachmentPreview.ts
Normal file
53
webui/src/features/campaigns/utils/attachmentPreview.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type AttachmentPreviewFileLike = {
|
||||
id: string;
|
||||
display_path: string;
|
||||
filename: string;
|
||||
linked_to_campaign?: boolean;
|
||||
};
|
||||
|
||||
export type AttachmentPreviewLike<T extends AttachmentPreviewFileLike> = {
|
||||
rules: Array<{matches: T[]}>;
|
||||
linkable_files?: T[];
|
||||
};
|
||||
|
||||
export function attachmentPreviewMatchedFiles<T extends AttachmentPreviewFileLike>(
|
||||
preview: AttachmentPreviewLike<T> | null
|
||||
): T[] {
|
||||
if (!preview) return [];
|
||||
const byId = new Map<string, T>();
|
||||
for (const rule of preview.rules) {
|
||||
for (const file of rule.matches) {
|
||||
const key = file.id || file.display_path;
|
||||
if (!key) continue;
|
||||
const existing = byId.get(key);
|
||||
if (existing) {
|
||||
byId.set(key, {
|
||||
...existing,
|
||||
linked_to_campaign: existing.linked_to_campaign !== false || file.linked_to_campaign !== false
|
||||
});
|
||||
} else {
|
||||
byId.set(key, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => {
|
||||
const linkOrder = Number(left.linked_to_campaign === false) - Number(right.linked_to_campaign === false);
|
||||
if (linkOrder !== 0) return linkOrder;
|
||||
return (left.display_path || left.filename).localeCompare(right.display_path || right.filename);
|
||||
});
|
||||
}
|
||||
|
||||
export function attachmentPreviewLinkableFiles<T extends AttachmentPreviewFileLike>(
|
||||
preview: AttachmentPreviewLike<T> | null
|
||||
): T[] {
|
||||
if (!preview) return [];
|
||||
const source = preview.linkable_files && preview.linkable_files.length > 0
|
||||
? preview.linkable_files
|
||||
: attachmentPreviewMatchedFiles(preview).filter((file) => file.linked_to_campaign === false);
|
||||
const byId = new Map<string, T>();
|
||||
for (const file of source) {
|
||||
const key = file.id || file.display_path;
|
||||
if (key) byId.set(key, file);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
@@ -22,15 +22,21 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
||||
raw.server = isRecord(raw.server) ? raw.server : {};
|
||||
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
||||
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
||||
const sourceAttachments = asRecord(raw.attachments);
|
||||
raw.attachments = {
|
||||
base_path: ".",
|
||||
allow_individual: false,
|
||||
send_without_attachments: true,
|
||||
send_without_attachments_behavior: "continue",
|
||||
global: [],
|
||||
missing_behavior: "ask",
|
||||
ambiguous_behavior: "ask",
|
||||
...asRecord(raw.attachments)
|
||||
...sourceAttachments
|
||||
};
|
||||
const normalizedAttachments = asRecord(raw.attachments);
|
||||
if (sourceAttachments.send_without_attachments_behavior === undefined) {
|
||||
normalizedAttachments.send_without_attachments_behavior = getBool(normalizedAttachments, "send_without_attachments", true) ? "continue" : "block";
|
||||
}
|
||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||
raw.validation_policy = {
|
||||
unsent_attachment_files: "warn",
|
||||
|
||||
@@ -60,6 +60,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
||||
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
||||
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
||||
"i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "All unique managed files matched by the current attachment rules are available in this list.",
|
||||
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
||||
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
||||
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
||||
@@ -95,6 +96,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||
"i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Attachment file links",
|
||||
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Attachment file links ({value0})",
|
||||
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
||||
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
||||
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
||||
@@ -105,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Keep original ZIP entry name",
|
||||
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Message filename template",
|
||||
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "ZIP entry name template",
|
||||
"i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.",
|
||||
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
||||
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
||||
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
||||
@@ -430,8 +434,17 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
||||
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
||||
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
||||
"i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock",
|
||||
"i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?",
|
||||
"i18n:govoplan-campaign.link_value_file.4d4ce740": "Link {value0} file",
|
||||
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
||||
"i18n:govoplan-campaign.link_value_files.88b7e6a7": "Link {value0} files",
|
||||
"i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Linked: already part of the campaign file snapshot.",
|
||||
"i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.",
|
||||
"i18n:govoplan-campaign.linked.a089f600": "Linked",
|
||||
"i18n:govoplan-campaign.linking.a5f54e0f": "Linking",
|
||||
"i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…",
|
||||
"i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…",
|
||||
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
||||
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
||||
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
||||
@@ -483,6 +496,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
||||
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
||||
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
||||
"i18n:govoplan-campaign.matched.1bf3ec5b": "Matched",
|
||||
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
||||
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
||||
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
||||
@@ -541,6 +555,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
||||
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
||||
"i18n:govoplan-campaign.name.709a2322": "Name",
|
||||
"i18n:govoplan-campaign.need_link.fa4ab530": "Need link",
|
||||
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
||||
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
||||
"i18n:govoplan-campaign.needs_attention.a126722e": "Needs attention",
|
||||
@@ -588,6 +603,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
||||
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
||||
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
||||
"i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "No managed files matched the current attachment patterns.",
|
||||
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
||||
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
||||
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
||||
@@ -693,6 +709,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
||||
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
||||
"i18n:govoplan-campaign.policy.bb9cf141": "Policy",
|
||||
"i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.",
|
||||
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
||||
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
||||
"i18n:govoplan-campaign.preview.f1fbb2b4": "Preview",
|
||||
@@ -757,6 +774,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
||||
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
||||
"i18n:govoplan-campaign.refresh.56e3badc": "Refresh",
|
||||
"i18n:govoplan-campaign.refreshing.505dddc9": "Refreshing",
|
||||
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
||||
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
||||
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
||||
@@ -886,6 +904,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
||||
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
||||
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
||||
"i18n:govoplan-campaign.shared_source.7d4a1bf2": "Shared source",
|
||||
"i18n:govoplan-campaign.shared_with.6203f449": "Shared with",
|
||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||
@@ -1019,6 +1038,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
||||
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
||||
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
||||
"i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.",
|
||||
"i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Unlinked: candidate match, potentially missing until linked.",
|
||||
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
||||
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
||||
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
||||
@@ -1092,7 +1113,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
||||
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Encryption: {value1}",
|
||||
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
||||
"i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} matched attachment file link",
|
||||
"i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} matched attachment file links",
|
||||
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
||||
"i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?",
|
||||
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
||||
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
||||
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
||||
@@ -1111,6 +1135,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
||||
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
||||
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
||||
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "View all {value0} matched attachment file links",
|
||||
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
||||
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
||||
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
||||
@@ -1187,6 +1212,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
||||
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
||||
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
||||
"i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen.",
|
||||
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
||||
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
||||
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
||||
@@ -1222,6 +1248,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||
"i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Verknüpfungen von Anhangsdateien",
|
||||
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Verknüpfungen von Anhangsdateien ({value0})",
|
||||
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
||||
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
||||
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
||||
@@ -1232,6 +1260,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Urspruenglichen ZIP-Eintragsnamen behalten",
|
||||
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Dateinamenvorlage fuer Nachrichten",
|
||||
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "Namensvorlage fuer ZIP-Eintraege",
|
||||
"i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.",
|
||||
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
||||
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
||||
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
||||
@@ -1557,8 +1586,17 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
||||
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
||||
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
||||
"i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock",
|
||||
"i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?",
|
||||
"i18n:govoplan-campaign.link_value_file.4d4ce740": "{value0} Datei verknüpfen",
|
||||
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
||||
"i18n:govoplan-campaign.linked.a089f600": "Linked",
|
||||
"i18n:govoplan-campaign.link_value_files.88b7e6a7": "{value0} Dateien verknüpfen",
|
||||
"i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots.",
|
||||
"i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.",
|
||||
"i18n:govoplan-campaign.linked.a089f600": "Verknüpft",
|
||||
"i18n:govoplan-campaign.linking.a5f54e0f": "Wird verknüpft",
|
||||
"i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…",
|
||||
"i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…",
|
||||
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
||||
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
||||
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
||||
@@ -1610,6 +1648,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
||||
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
||||
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
||||
"i18n:govoplan-campaign.matched.1bf3ec5b": "Treffer",
|
||||
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
||||
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
||||
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
||||
@@ -1668,6 +1707,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
||||
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
||||
"i18n:govoplan-campaign.name.709a2322": "Name",
|
||||
"i18n:govoplan-campaign.need_link.fa4ab530": "Zu verknüpfen",
|
||||
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
||||
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
||||
"i18n:govoplan-campaign.needs_attention.a126722e": "Benötigt Aufmerksamkeit",
|
||||
@@ -1715,6 +1755,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
||||
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
||||
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
||||
"i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern.",
|
||||
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
||||
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
||||
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
||||
@@ -1820,6 +1861,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
||||
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
||||
"i18n:govoplan-campaign.policy.bb9cf141": "Richtlinie",
|
||||
"i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden.",
|
||||
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
||||
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
||||
"i18n:govoplan-campaign.preview.f1fbb2b4": "Vorschau",
|
||||
@@ -1883,7 +1925,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81": "Recording the completed message review…",
|
||||
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
||||
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
||||
"i18n:govoplan-campaign.refresh.56e3badc": "Refresh",
|
||||
"i18n:govoplan-campaign.refresh.56e3badc": "Aktualisieren",
|
||||
"i18n:govoplan-campaign.refreshing.505dddc9": "Wird aktualisiert",
|
||||
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
||||
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
||||
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
||||
@@ -2013,6 +2056,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
||||
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
||||
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
||||
"i18n:govoplan-campaign.shared_source.7d4a1bf2": "Gemeinsame Quelle",
|
||||
"i18n:govoplan-campaign.shared_with.6203f449": "Freigegeben für",
|
||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||
@@ -2146,6 +2190,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
||||
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
||||
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
||||
"i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen.",
|
||||
"i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann.",
|
||||
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
||||
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
||||
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
||||
@@ -2219,7 +2265,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
||||
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Verschlüsselung: {value1}",
|
||||
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
||||
"i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} gefundene Verknüpfung einer Anhangsdatei",
|
||||
"i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} gefundene Verknüpfungen von Anhangsdateien",
|
||||
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
||||
"i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?",
|
||||
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
||||
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
||||
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
||||
@@ -2238,6 +2287,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
||||
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
||||
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
||||
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen",
|
||||
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
||||
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
||||
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
||||
|
||||
@@ -11,7 +11,6 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
|
||||
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
|
||||
{ id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
|
||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
|
||||
|
||||
},
|
||||
@@ -56,4 +55,4 @@ export default function SectionSidebar({
|
||||
|
||||
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
|
||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,6 +706,53 @@
|
||||
align-items: center;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.campaign-header-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px 18px;
|
||||
}
|
||||
.recipient-address-header-control {
|
||||
min-width: 0;
|
||||
}
|
||||
.recipient-address-header-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 6px 9px;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.recipient-address-header-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.recipient-address-header-text > span:first-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recipient-address-header-summary.is-empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.recipient-address-inline-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.recipient-address-inline-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.recipient-add-row td {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
@@ -922,13 +969,160 @@
|
||||
|
||||
/* Recipient/profile split pages. */
|
||||
.recipient-address-table th:nth-child(1),
|
||||
.recipient-address-table td:nth-child(1) { width: 42px; }
|
||||
.recipient-address-table td:nth-child(1) { width: 72px; }
|
||||
.recipient-address-table th:last-child,
|
||||
.recipient-address-table td:last-child { width: 123px; }
|
||||
.recipient-address-table td { vertical-align: top; }
|
||||
.recipient-address-table .email-address-input { min-width: 220px; }
|
||||
.recipient-address-table td:last-child { width: 180px; }
|
||||
.recipient-address-table td { vertical-align: middle; }
|
||||
.recipient-address-table .toggle-switch { white-space: nowrap; }
|
||||
|
||||
.recipient-address-editor-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.recipient-address-editor-trigger:hover,
|
||||
.recipient-address-editor-trigger:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-subtle);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipient-address-editor-trigger.is-readonly {
|
||||
cursor: default;
|
||||
opacity: .78;
|
||||
}
|
||||
|
||||
.recipient-address-editor-trigger.is-readonly:hover {
|
||||
border-color: var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.recipient-address-editor-trigger svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.recipient-address-editor-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recipient-data-address.is-empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recipient-address-editor-modal {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.recipient-address-editor-body {
|
||||
max-height: min(72vh, 760px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.recipient-address-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recipient-address-category {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.recipient-address-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recipient-address-category-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.recipient-address-category-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.recipient-address-mode-static {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.recipient-address-lines {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recipient-address-empty-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipient-address-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipient-address-edit-line {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipient-address-edit-line input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipient-address-line-actions {
|
||||
width: auto;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.recipient-merged-data-table .recipient-field-input {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.recipient-data-table td { vertical-align: top; }
|
||||
.recipient-data-identity-cell { white-space: normal !important; }
|
||||
.recipient-data-identity {
|
||||
@@ -973,6 +1167,18 @@
|
||||
.recipient-data-table td:nth-child(3) { min-width: 192px; }
|
||||
.recipient-index-cell { white-space: nowrap; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.recipient-address-empty-row,
|
||||
.recipient-address-edit-line {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.recipient-address-category-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.locked-version-notice .alert-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1067,6 +1273,12 @@
|
||||
}
|
||||
|
||||
/* Overview version history refinements. */
|
||||
.version-history-table-surface {
|
||||
margin-top: 14px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.version-history-table .current-version-row td {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1128,6 +1340,10 @@
|
||||
min-height: 68px;
|
||||
}
|
||||
|
||||
.message-preview-modal .modal-footer > .button-row {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-body,
|
||||
.message-preview-modal .message-display-html-frame {
|
||||
height: clamp(280px, 45vh, 520px);
|
||||
@@ -1247,7 +1463,7 @@
|
||||
}
|
||||
/* Review & Send workflow page. */
|
||||
.review-send-page {
|
||||
--review-flow-purple: var(--review-flow-purple);
|
||||
--review-flow-partial: var(--review-flow-purple, #9b86c7);
|
||||
}
|
||||
|
||||
.review-flow-navigation {
|
||||
@@ -1344,7 +1560,11 @@
|
||||
height: 2px;
|
||||
margin: 21px 2px 0;
|
||||
flex: 0 0 auto;
|
||||
background: linear-gradient(to right, var(--review-nav-color), var(--review-nav-next-color));
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--review-nav-line-color, var(--review-nav-color)),
|
||||
var(--review-nav-line-next-color, var(--review-nav-next-color, var(--review-nav-color)))
|
||||
);
|
||||
opacity: .82;
|
||||
}
|
||||
|
||||
@@ -1395,7 +1615,11 @@
|
||||
min-height: 54px;
|
||||
margin: 4px 0 -14px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to bottom, var(--review-stage-color), var(--review-next-stage-color));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--review-stage-line-color, var(--review-stage-color)),
|
||||
var(--review-next-stage-line-color, var(--review-next-stage-color, var(--review-stage-color)))
|
||||
);
|
||||
opacity: .82;
|
||||
}
|
||||
|
||||
@@ -1452,12 +1676,132 @@
|
||||
.review-flow-state-badge[data-state="active"],
|
||||
.review-flow-state-badge[data-state="running"] { --review-badge-color: var(--blue); }
|
||||
.review-flow-state-badge[data-state="partial"],
|
||||
.review-flow-state-badge[data-state="stale"] { --review-badge-color: var(--review-flow-purple); }
|
||||
.review-flow-state-badge[data-state="stale"] { --review-badge-color: var(--review-flow-partial); }
|
||||
|
||||
.review-flow-stage-content {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.attachment-linking-preview {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.attachment-linking-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.attachment-linking-header h3 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list.is-compact {
|
||||
max-height: min(240px, 32vh);
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list:focus-visible {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 7px 8px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.attachment-linking-file-list li > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.attachment-linking-file-list li[data-linked="false"] > svg {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.attachment-linking-file-list li > span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list strong,
|
||||
.attachment-linking-file-list small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.attachment-linking-file-list small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.review-flow-fact-detail-button {
|
||||
border: 0;
|
||||
border-bottom: 1px solid currentColor;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.review-flow-fact-detail-button:hover,
|
||||
.review-flow-fact-detail-button:focus-visible {
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.attachment-linking-detail-modal {
|
||||
width: min(760px, calc(100vw - 48px));
|
||||
height: min(680px, calc(100dvh - 48px));
|
||||
}
|
||||
|
||||
.attachment-linking-detail-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attachment-linking-detail-body > .small-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachment-linking-file-list.is-detail {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.review-flow-stage-card.is-locked .review-flow-stage-header {
|
||||
opacity: .58;
|
||||
filter: grayscale(.25);
|
||||
@@ -1587,8 +1931,8 @@
|
||||
background: color-mix(in srgb, var(--red) 10%, var(--surface));
|
||||
}
|
||||
.review-flow-inline-note.is-stale {
|
||||
border-left-color: var(--review-flow-purple);
|
||||
background: color-mix(in srgb, var(--review-flow-purple) 11%, var(--surface));
|
||||
border-left-color: var(--review-flow-partial);
|
||||
background: color-mix(in srgb, var(--review-flow-partial) 11%, var(--surface));
|
||||
}
|
||||
|
||||
.review-flow-result-line {
|
||||
@@ -1693,6 +2037,15 @@
|
||||
|
||||
.review-flow-data-section > .data-grid-shell {
|
||||
max-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.review-flow-data-section > .data-grid-shell > .data-grid-scroll-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.review-flow-data-section > .small-note {
|
||||
@@ -1779,6 +2132,7 @@
|
||||
.attachment-zip-table-wrap .toggle-switch-row {
|
||||
min-height: 36px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-zip-table-wrap select {
|
||||
|
||||
113
webui/tests/review-preview-ui.test.ts
Normal file
113
webui/tests/review-preview-ui.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
attachmentPreviewLinkableFiles,
|
||||
attachmentPreviewMatchedFiles,
|
||||
type AttachmentPreviewFileLike,
|
||||
type AttachmentPreviewLike
|
||||
} from "../src/features/campaigns/utils/attachmentPreview";
|
||||
|
||||
declare function require(name: string): {
|
||||
readFileSync(path: string, encoding: string): string;
|
||||
createHash(algorithm: string): {
|
||||
update(value: string): {digest(encoding: "hex"): string};
|
||||
};
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { createHash } = require("node:crypto");
|
||||
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function previewFile(index: number): AttachmentPreviewFileLike {
|
||||
return {
|
||||
id: `file-${index}`,
|
||||
display_path: `/campaign/attachments/file-${String(index).padStart(2, "0")}.pdf`,
|
||||
filename: `file-${String(index).padStart(2, "0")}.pdf`,
|
||||
linked_to_campaign: index % 2 === 0
|
||||
};
|
||||
}
|
||||
|
||||
function previewRule(matches: AttachmentPreviewFileLike[]): {matches: AttachmentPreviewFileLike[]} {
|
||||
return {
|
||||
matches
|
||||
};
|
||||
}
|
||||
|
||||
const files = Array.from({ length: 25 }, (_, index) => previewFile(index));
|
||||
const duplicatePromotedToLinked = { ...files[1], linked_to_campaign: true };
|
||||
const preview: AttachmentPreviewLike<AttachmentPreviewFileLike> = {
|
||||
rules: [
|
||||
previewRule(files.slice(0, 14)),
|
||||
previewRule([...files.slice(14), duplicatePromotedToLinked])
|
||||
]
|
||||
};
|
||||
|
||||
const matched = attachmentPreviewMatchedFiles(preview);
|
||||
assert(matched.length === 25, "all unique attachment matches remain available; the review model must not truncate at twelve");
|
||||
assert(matched.find((file) => file.id === "file-1")?.linked_to_campaign === true, "duplicate matches preserve the strongest linked state");
|
||||
assert(matched.every((file, index) => index === 0 || Number(matched[index - 1].linked_to_campaign === false) <= Number(file.linked_to_campaign === false)), "linked matches sort before unlinked candidates");
|
||||
|
||||
const fallbackLinkable = attachmentPreviewLinkableFiles(preview);
|
||||
assert(fallbackLinkable.length === 11, "linkable fallback contains every unique unlinked candidate");
|
||||
|
||||
const explicitLinkablePreview = {
|
||||
...preview,
|
||||
linkable_files: [files[3], files[3], files[5]]
|
||||
};
|
||||
assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap");
|
||||
|
||||
const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
|
||||
assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice");
|
||||
assert(reviewSource.includes("<AttachmentLinkingFileList files={matchedFiles} compact />"), "compact review exposes the complete bounded attachment list");
|
||||
assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog");
|
||||
assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions");
|
||||
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract");
|
||||
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract");
|
||||
assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English");
|
||||
assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English");
|
||||
assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English");
|
||||
assert(!reviewSource.includes("Linked: already part of the campaign file snapshot."), "attachment link-state detail is not raw English");
|
||||
assert(!reviewSource.includes("Unlinked: candidate match, potentially missing until linked."), "unlinked attachment detail is not raw English");
|
||||
|
||||
const newTranslations = [
|
||||
["i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5", "All unique managed files matched by the current attachment rules are available in this list.", "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen."],
|
||||
["i18n:govoplan-campaign.attachment_file_links.0be74fd1", "Attachment file links", "Verknüpfungen von Anhangsdateien"],
|
||||
["i18n:govoplan-campaign.attachment_file_links_value.ce230e30", "Attachment file links ({value0})", "Verknüpfungen von Anhangsdateien ({value0})"],
|
||||
["i18n:govoplan-campaign.link_value_file.4d4ce740", "Link {value0} file", "{value0} Datei verknüpfen"],
|
||||
["i18n:govoplan-campaign.link_value_files.88b7e6a7", "Link {value0} files", "{value0} Dateien verknüpfen"],
|
||||
["i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc", "Linked: already part of the campaign file snapshot.", "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots."],
|
||||
["i18n:govoplan-campaign.linking.a5f54e0f", "Linking", "Wird verknüpft"],
|
||||
["i18n:govoplan-campaign.matched.1bf3ec5b", "Matched", "Treffer"],
|
||||
["i18n:govoplan-campaign.need_link.fa4ab530", "Need link", "Zu verknüpfen"],
|
||||
["i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c", "No managed files matched the current attachment patterns.", "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern."],
|
||||
["i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af", "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.", "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden."],
|
||||
["i18n:govoplan-campaign.refreshing.505dddc9", "Refreshing", "Wird aktualisiert"],
|
||||
["i18n:govoplan-campaign.shared_source.7d4a1bf2", "Shared source", "Gemeinsame Quelle"],
|
||||
["i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998", "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.", "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen."],
|
||||
["i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433", "Unlinked: candidate match, potentially missing until linked.", "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann."],
|
||||
["i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824", "{value0} matched attachment file link", "{value0} gefundene Verknüpfung einer Anhangsdatei"],
|
||||
["i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a", "{value0} matched attachment file links", "{value0} gefundene Verknüpfungen von Anhangsdateien"],
|
||||
["i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", "View all {value0} matched attachment file links", "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen"]
|
||||
] as const;
|
||||
|
||||
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
|
||||
for (const [key, english, german] of newTranslations) {
|
||||
const digest = createHash("sha1").update(english).digest("hex").slice(0, 8);
|
||||
assert(key.endsWith(`.${digest}`), `${key} uses the stable SHA-1 suffix for its English source`);
|
||||
assert(translationCatalog.split(`"${key}"`).length - 1 === 2, `${key} is present exactly once in both language catalogs`);
|
||||
assert(translationCatalog.includes(`"${key}": ${JSON.stringify(english)}`), `${key} has its English catalog value`);
|
||||
assert(translationCatalog.includes(`"${key}": ${JSON.stringify(german)}`), `${key} has its German catalog value`);
|
||||
}
|
||||
|
||||
const overlaySource = readFileSync("src/features/campaigns/components/MessagePreviewOverlay.tsx", "utf8");
|
||||
assert(overlaySource.includes("message-preview-backdrop"), "message previews expose a layout-specific responsive backdrop hook");
|
||||
assert(overlaySource.includes("{actions && <div"), "built-message footer actions remain in the stable shared preview footer");
|
||||
assert(overlaySource.includes('aria-label={closeLabel}'), "the preview close control has an accessible label");
|
||||
|
||||
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
|
||||
assert(styles.includes("height: min(780px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
|
||||
assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height");
|
||||
assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule");
|
||||
assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface");
|
||||
assert(styles.includes(".attachment-linking-file-list.is-detail"), "attachment detail links use an independent scroll surface");
|
||||
20
webui/tsconfig.review-preview-tests.json
Normal file
20
webui/tsconfig.review-preview-tests.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".review-preview-test-build",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/review-preview-ui.test.ts",
|
||||
"src/features/campaigns/utils/attachmentPreview.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user