Use migration history for template issue summaries
This commit is contained in:
parent
447a89923a
commit
c22d26bcf6
|
|
@ -225,6 +225,20 @@ Single-page app in `web/static/`. No build step — plain HTML + ES modules.
|
||||||
|
|
||||||
CSS uses DocuSign 2024 brand design tokens defined in `css/tokens.css`.
|
CSS uses DocuSign 2024 brand design tokens defined in `css/tokens.css`.
|
||||||
|
|
||||||
|
### Template Issue Summary
|
||||||
|
|
||||||
|
The Templates and Issues & Warnings pages use `/api/templates/status`. A
|
||||||
|
template is shown as `Clean` only when all of these are empty:
|
||||||
|
|
||||||
|
- validation `blockers`
|
||||||
|
- validation `warnings`
|
||||||
|
- composition `field_issues`
|
||||||
|
|
||||||
|
On the web server, migration downloads are temporary. If no persistent
|
||||||
|
`downloads/` folder exists for re-analysis, `/api/templates/status` falls back
|
||||||
|
to the current browser session's `migration-output/.history.json` records so
|
||||||
|
field issues discovered during migration still appear in the Templates summary.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Design
|
## Security Design
|
||||||
|
|
|
||||||
|
|
@ -286,3 +286,49 @@ def test_status_includes_field_issues_when_template_has_mapping_caveats(tmp_path
|
||||||
t = resp.json()["templates"][0]
|
t = resp.json()["templates"][0]
|
||||||
assert t["analysis_status"] == "analyzed"
|
assert t["analysis_status"] == "analyzed"
|
||||||
assert any(issue["code"] == "HIDE_ACTION" for issue in t["field_issues"])
|
assert any(issue["code"] == "HIDE_ACTION" for issue in t["field_issues"])
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_status_uses_history_field_issues_when_download_is_not_persistent(tmp_path, monkeypatch):
|
||||||
|
"""Server-side temp downloads are gone after migration, so status falls back to history."""
|
||||||
|
import json
|
||||||
|
import web.routers.templates as templates_module
|
||||||
|
|
||||||
|
history_path = tmp_path / ".history.json"
|
||||||
|
history_path.write_text(json.dumps([
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-23T12:00:00Z",
|
||||||
|
"owner_session_id": "legacy",
|
||||||
|
"adobe_template_id": "adobe-history",
|
||||||
|
"adobe_template_name": "History Template",
|
||||||
|
"warnings": ["Skipped: template already exists (overwrite_if_exists=false)"],
|
||||||
|
"blockers": [],
|
||||||
|
"field_issues": [
|
||||||
|
{
|
||||||
|
"code": "FIELD_TYPE_SKIPPED",
|
||||||
|
"field_name": "Image 1",
|
||||||
|
"message": "Image 1 was skipped",
|
||||||
|
"severity": "warning",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
monkeypatch.setattr(templates_module, "_HISTORY_FILE", str(history_path))
|
||||||
|
|
||||||
|
respx.get(f"{ADOBE_BASE}/libraryDocuments").mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"libraryDocumentList": [
|
||||||
|
{"id": "adobe-history", "name": "History Template", "modifiedDate": "2026-04-10"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
respx.get(f"{DS_BASE}/v2.1/accounts/{DS_ACCOUNT}/templates").mock(
|
||||||
|
return_value=httpx.Response(200, json={"envelopeTemplates": []})
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/templates/status", cookies={_COOKIE_NAME: _adobe_session()})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
t = resp.json()["templates"][0]
|
||||||
|
assert t["analysis_status"] == "history"
|
||||||
|
assert t["warnings"] == []
|
||||||
|
assert t["field_issues"][0]["code"] == "FIELD_TYPE_SKIPPED"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ Computes per-template migration status for the side-by-side UI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -21,6 +23,10 @@ from web.session import get_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_HISTORY_FILE = os.path.abspath(os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "migration-output", ".history.json"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _require_adobe(session: dict) -> Optional[JSONResponse]:
|
def _require_adobe(session: dict) -> Optional[JSONResponse]:
|
||||||
if not session.get("adobe_access_token"):
|
if not session.get("adobe_access_token"):
|
||||||
|
|
@ -162,6 +168,13 @@ async def template_status(request: Request):
|
||||||
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
status = "needs_update" if adobe_modified > ds_modified else "migrated"
|
||||||
|
|
||||||
analysis = _get_template_analysis(t.get("id", ""), name)
|
analysis = _get_template_analysis(t.get("id", ""), name)
|
||||||
|
if not _has_analysis_issues(analysis):
|
||||||
|
history_analysis = _get_history_analysis(
|
||||||
|
t.get("id", ""),
|
||||||
|
name,
|
||||||
|
session.get("_session_id") or "legacy",
|
||||||
|
)
|
||||||
|
analysis = _merge_analysis(analysis, history_analysis)
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"adobe_id": t.get("id"),
|
"adobe_id": t.get("id"),
|
||||||
|
|
@ -235,6 +248,98 @@ def _find_downloaded_template(template_id: str, template_name: str) -> Path | No
|
||||||
return next((c for c in candidates if c.is_dir()), None)
|
return next((c for c in candidates if c.is_dir()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_history_analysis(template_id: str, template_name: str, session_scope: str) -> dict:
|
||||||
|
"""
|
||||||
|
Return the latest issue details captured during migration for this template.
|
||||||
|
|
||||||
|
The production web migration flow downloads Adobe template data to a temp
|
||||||
|
directory, so the Templates page may not have persistent local downloads to
|
||||||
|
re-analyze. Migration history is the source of truth for issues discovered
|
||||||
|
during an actual migration attempt.
|
||||||
|
"""
|
||||||
|
analysis = {
|
||||||
|
"blockers": [],
|
||||||
|
"warnings": [],
|
||||||
|
"field_issues": [],
|
||||||
|
"status": "not_found",
|
||||||
|
}
|
||||||
|
matching_records = [
|
||||||
|
record for record in _load_history()
|
||||||
|
if record.get("owner_session_id", "legacy") == session_scope
|
||||||
|
and (
|
||||||
|
record.get("adobe_template_id") == template_id
|
||||||
|
or record.get("adobe_template_name") == template_name
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not matching_records:
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
matching_records.sort(key=lambda record: record.get("timestamp", ""), reverse=True)
|
||||||
|
for record in matching_records:
|
||||||
|
blockers = record.get("blockers") or []
|
||||||
|
warnings = _template_warnings(record.get("warnings") or [])
|
||||||
|
field_issues = record.get("field_issues") or []
|
||||||
|
if blockers or warnings or field_issues:
|
||||||
|
analysis["blockers"] = blockers
|
||||||
|
analysis["warnings"] = warnings
|
||||||
|
analysis["field_issues"] = field_issues
|
||||||
|
analysis["status"] = "history"
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
analysis["status"] = "history_clean"
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
def _load_history() -> list:
|
||||||
|
if not os.path.exists(_HISTORY_FILE):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(_HISTORY_FILE, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _template_warnings(warnings: list[str]) -> list[str]:
|
||||||
|
"""Remove operational migration messages that should not make a template look risky."""
|
||||||
|
return [
|
||||||
|
warning for warning in warnings
|
||||||
|
if not str(warning).startswith("Skipped: template already exists")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_analysis_issues(analysis: dict) -> bool:
|
||||||
|
return bool(analysis["blockers"] or analysis["warnings"] or analysis["field_issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_analysis(primary: dict, fallback: dict) -> dict:
|
||||||
|
if fallback["status"] in ("not_found", "history_clean"):
|
||||||
|
return primary
|
||||||
|
merged = {
|
||||||
|
"blockers": _dedupe([*primary["blockers"], *fallback["blockers"]]),
|
||||||
|
"warnings": _dedupe([*primary["warnings"], *fallback["warnings"]]),
|
||||||
|
"field_issues": _dedupe_field_issues([*primary["field_issues"], *fallback["field_issues"]]),
|
||||||
|
"status": fallback["status"] if primary["status"] == "not_downloaded" else primary["status"],
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_field_issues(items: list[dict]) -> list[dict]:
|
||||||
|
seen = set()
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
key = (
|
||||||
|
item.get("code"),
|
||||||
|
item.get("field_name"),
|
||||||
|
item.get("message"),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _dedupe(items: list[str]) -> list[str]:
|
def _dedupe(items: list[str]) -> list[str]:
|
||||||
seen = set()
|
seen = set()
|
||||||
result = []
|
result = []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue