This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.203 補 `/observability/ppt_audit_history` 單檔 PDF 預熱操作:未快取的可預覽 PPTX 會顯示「預熱 PDF」,透過 JSON 端點建立 PDF 快取並即時更新頁面狀態。
|
||||
- V10.201 強化 `/observability/ppt_audit_history` 線上預覽可診斷性:產線清單不觸發轉檔即可顯示 PDF 預覽快取狀態,Pipeline Health、Preview Workbench 與已產檔案表同步標記「PDF 快取 / 首次轉檔」。
|
||||
- V10.199 讓 `/observability/ppt_audit_history` Action Queue 可直接處理異常:待補齊與異常優先項目新增單一報表「重跑」按鈕,透過既有非阻塞背景產線排入指定 report_type。
|
||||
- V10.197 強化 `/observability/ppt_audit_history` Action Queue:新增「異常優先」lane,將產出失敗、PPTX 檔案異常、視覺 QA 失敗拉到最前面,並顯示錯誤訊息與可預覽入口。
|
||||
|
||||
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.202"
|
||||
SYSTEM_VERSION = "V10.203"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -1860,6 +1860,68 @@ def ppt_audit_generate_missing():
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
def _resolve_ppt_report_path(filename: str):
|
||||
"""在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。"""
|
||||
import os
|
||||
from utils.security import safe_join
|
||||
|
||||
reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
|
||||
safe_path = safe_join(reports_dir, filename)
|
||||
if not safe_path.exists() or not safe_path.is_file():
|
||||
return None, ('檔案不存在', 404)
|
||||
if safe_path.suffix.lower() != '.pptx':
|
||||
return None, ('不支援的檔案格式', 400)
|
||||
return safe_path, None
|
||||
|
||||
|
||||
def _validate_pptx_for_preview(safe_path):
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(safe_path, 'r') as zf:
|
||||
bad = zf.testzip()
|
||||
if bad is not None:
|
||||
return f'PPT 檔案損毀,無法預覽(損毀區段:{bad})'
|
||||
except zipfile.BadZipFile:
|
||||
return 'PPT 檔案損毀,無法預覽(非有效 zip)'
|
||||
except Exception as e:
|
||||
return f'預覽檢查失敗:{type(e).__name__}'
|
||||
return None
|
||||
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit_file/<path:filename>/prewarm', methods=['POST'])
|
||||
@login_required
|
||||
def ppt_audit_file_prewarm(filename: str):
|
||||
"""建立單一 PPT 的 PDF 預覽快取,並回傳 JSON 狀態。"""
|
||||
try:
|
||||
safe_path, error_response = _resolve_ppt_report_path(filename)
|
||||
if error_response:
|
||||
message, status_code = error_response
|
||||
return jsonify({'ok': False, 'error': message}), status_code
|
||||
|
||||
validation_error = _validate_pptx_for_preview(safe_path)
|
||||
if validation_error:
|
||||
return jsonify({'ok': False, 'error': validation_error}), 409
|
||||
|
||||
from services.ppt_preview_service import build_ppt_preview
|
||||
|
||||
preview = build_ppt_preview(safe_path)
|
||||
if not preview.ok or not preview.pdf_path:
|
||||
return jsonify({'ok': False, 'error': preview.error or '無法產生預覽'}), 409
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'filename': safe_path.name,
|
||||
'cache_hit': bool(preview.cache_hit),
|
||||
'converter': preview.converter,
|
||||
'message': 'PDF 預覽快取已建立' if not preview.cache_hit else 'PDF 預覽快取已存在',
|
||||
})
|
||||
except ValueError:
|
||||
return jsonify({'ok': False, 'error': '非法路徑'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
|
||||
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit_file/<path:filename>')
|
||||
@login_required
|
||||
def ppt_audit_file(filename: str):
|
||||
@@ -1871,29 +1933,15 @@ def ppt_audit_file(filename: str):
|
||||
"""
|
||||
action = (request.args.get('action', 'view') or 'view').strip().lower()
|
||||
try:
|
||||
import os
|
||||
import zipfile
|
||||
from utils.security import safe_join
|
||||
|
||||
reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')
|
||||
safe_path = safe_join(reports_dir, filename)
|
||||
|
||||
if not safe_path.exists() or not safe_path.is_file():
|
||||
return '檔案不存在', 404
|
||||
|
||||
if safe_path.suffix.lower() != '.pptx':
|
||||
return '不支援的檔案格式', 400
|
||||
safe_path, error_response = _resolve_ppt_report_path(filename)
|
||||
if error_response:
|
||||
message, status_code = error_response
|
||||
return message, status_code
|
||||
|
||||
if action in ('view', 'pdf'):
|
||||
try:
|
||||
with zipfile.ZipFile(safe_path, 'r') as zf:
|
||||
bad = zf.testzip()
|
||||
if bad is not None:
|
||||
return f'PPT 檔案損毀,無法預覽(損毀區段:{bad})', 409
|
||||
except zipfile.BadZipFile:
|
||||
return 'PPT 檔案損毀,無法預覽(非有效 zip)', 409
|
||||
except Exception as e:
|
||||
return f'預覽檢查失敗:{type(e).__name__}', 409
|
||||
validation_error = _validate_pptx_for_preview(safe_path)
|
||||
if validation_error:
|
||||
return validation_error, 409
|
||||
|
||||
if action in ('view', 'pdf'):
|
||||
from services.ppt_preview_service import build_ppt_preview
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<span>{{ f.source }}</span>
|
||||
{% if f.file_exists and f.is_valid_ppt %}<span class="status-good">可預覽</span>{% else %}<span class="status-bad">需回補</span>{% endif %}
|
||||
{% if f.file_exists and f.is_valid_ppt %}
|
||||
{% if f.preview_cache_ready %}<span class="status-blue">PDF 快取</span>{% else %}<span class="status-warn">首次轉檔</span>{% endif %}
|
||||
{% if f.preview_cache_ready %}<span class="status-blue" data-ppt-preview-state="{{ f.name }}">PDF 快取</span>{% else %}<span class="status-warn" data-ppt-preview-state="{{ f.name }}">首次轉檔</span>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ppt-file-actions">
|
||||
@@ -119,6 +119,11 @@
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-eye me-1"></i>線上預覽
|
||||
</a>
|
||||
{% if not f.preview_cache_ready %}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-preview data-ppt-filename="{{ f.name }}">
|
||||
<i class="fas fa-fire me-1"></i>預熱 PDF
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if f.file_exists %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name, action='download') }}">
|
||||
@@ -423,7 +428,7 @@
|
||||
{% endif %}
|
||||
{% if f.file_exists and f.is_valid_ppt %}
|
||||
<div>
|
||||
<small class="{{ 'status-blue' if f.preview_cache_ready else 'status-warn' }}">
|
||||
<small class="{{ 'status-blue' if f.preview_cache_ready else 'status-warn' }}" data-ppt-preview-state="{{ f.name }}">
|
||||
{% if f.preview_cache_ready %}
|
||||
PDF 預覽快取已建立{% if f.preview_cache_mtime %} · {{ f.preview_cache_mtime }}{% endif %}
|
||||
{% else %}
|
||||
@@ -440,6 +445,11 @@
|
||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=f.name) }}" target="_blank" rel="noopener">
|
||||
<i class="fas fa-eye me-1"></i>線上預覽
|
||||
</a>
|
||||
{% if not f.preview_cache_ready %}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-preview data-ppt-filename="{{ f.name }}">
|
||||
<i class="fas fa-fire me-1"></i>預熱 PDF
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="small status-bad">檔案不可預覽</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -315,6 +315,62 @@ def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_pat
|
||||
assert '下載 PPTX' in html
|
||||
|
||||
|
||||
def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path):
|
||||
"""未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。"""
|
||||
import zipfile
|
||||
|
||||
reports_dir = tmp_path / 'reports'
|
||||
reports_dir.mkdir()
|
||||
pptx = reports_dir / 'ocbot_daily_20260518.pptx'
|
||||
with zipfile.ZipFile(pptx, 'w') as zf:
|
||||
zf.writestr('[Content_Types].xml', '<Types></Types>')
|
||||
|
||||
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
|
||||
monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache'))
|
||||
|
||||
r = client.get('/observability/ppt_audit_history?month=2026-05')
|
||||
html = r.data.decode('utf-8')
|
||||
|
||||
assert r.status_code == 200
|
||||
assert '首次轉檔' in html
|
||||
assert 'data-ppt-prewarm-preview' in html
|
||||
assert 'data-ppt-filename="ocbot_daily_20260518.pptx"' in html
|
||||
assert '預熱 PDF' in html
|
||||
|
||||
|
||||
def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_path):
|
||||
"""預熱端點應回 JSON,不直接把 PDF body 丟給前端。"""
|
||||
import zipfile
|
||||
from services import ppt_preview_service as preview_svc
|
||||
|
||||
reports_dir = tmp_path / 'reports'
|
||||
reports_dir.mkdir()
|
||||
pptx = reports_dir / 'ocbot_daily_20260518.pptx'
|
||||
pdf = reports_dir / 'preview.pdf'
|
||||
with zipfile.ZipFile(pptx, 'w') as zf:
|
||||
zf.writestr('[Content_Types].xml', '<Types></Types>')
|
||||
|
||||
monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
|
||||
monkeypatch.setattr(
|
||||
preview_svc,
|
||||
'build_ppt_preview',
|
||||
lambda *_args, **_kwargs: preview_svc.PPTPreviewResult(
|
||||
ok=True,
|
||||
pdf_path=str(pdf),
|
||||
cache_hit=False,
|
||||
converter='/usr/bin/libreoffice',
|
||||
),
|
||||
)
|
||||
|
||||
r = client.post('/observability/ppt_audit_file/ocbot_daily_20260518.pptx/prewarm')
|
||||
data = r.get_json()
|
||||
|
||||
assert r.status_code == 200
|
||||
assert data['ok'] is True
|
||||
assert data['filename'] == 'ocbot_daily_20260518.pptx'
|
||||
assert data['message'] == 'PDF 預覽快取已建立'
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /observability/host_health
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -595,6 +595,7 @@
|
||||
|
||||
function initPptAutoGeneration() {
|
||||
const panel = document.querySelector('[data-ppt-auto-generation]');
|
||||
const pageStatus = document.querySelector('[data-ppt-auto-status]');
|
||||
document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => {
|
||||
if (button.dataset.bound === '1') return;
|
||||
button.dataset.bound = '1';
|
||||
@@ -602,6 +603,53 @@
|
||||
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(button => {
|
||||
if (button.dataset.bound === '1') return;
|
||||
button.dataset.bound = '1';
|
||||
button.addEventListener('click', async () => {
|
||||
const filename = button.dataset.pptFilename || '';
|
||||
if (!filename) return;
|
||||
const originalHtml = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>預熱中';
|
||||
if (pageStatus) {
|
||||
pageStatus.classList.add('is-working');
|
||||
pageStatus.textContent = `${filename} 正在建立 PDF 預覽快取。`;
|
||||
}
|
||||
try {
|
||||
const response = await postJson(`/observability/ppt_audit_file/${encodeURIComponent(filename)}/prewarm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || '預熱失敗');
|
||||
}
|
||||
document.querySelectorAll('[data-ppt-preview-state]').forEach(node => {
|
||||
if (node.dataset.pptPreviewState !== filename) return;
|
||||
node.classList.remove('status-warn');
|
||||
node.classList.add('status-blue');
|
||||
node.textContent = 'PDF 預覽快取已建立';
|
||||
});
|
||||
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(node => {
|
||||
if (node.dataset.pptFilename !== filename) return;
|
||||
node.innerHTML = '<i class="fas fa-check me-1"></i>已快取';
|
||||
node.disabled = true;
|
||||
});
|
||||
if (pageStatus) {
|
||||
pageStatus.textContent = data.message || `${filename} 的 PDF 預覽快取已建立。`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ppt_preview_prewarm_failed', error);
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHtml;
|
||||
if (pageStatus) {
|
||||
pageStatus.classList.remove('is-working');
|
||||
pageStatus.textContent = 'PDF 預覽預熱失敗,請稍後再試或直接開啟線上預覽。';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user