Skip to content

Commit e459189

Browse files
author
Dennis Braun
committed
Split incident report: editable complaint letter + separate technical PDF
- New /api/complaint endpoint returns complaint text as JSON - Report modal now has two steps: 1. Settings (days, language) + customer info fields (name, number, address) 2. Editable complaint letter textarea + copy button + PDF download - Customer data is injected into the letter template - PDF remains as pure technical report (attachment) - Full i18n for all new UI elements (en/de/fr/es) - report.py: new generate_complaint_text() function
1 parent bf85d73 commit e459189

File tree

7 files changed

+223
-25
lines changed

7 files changed

+223
-25
lines changed

app/i18n/de.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,5 +198,13 @@
198198
"bqm_setup_step_2": "Aktiviere <b>WAN-Ping/ICMP</b> auf deinem Router, damit er auf externe Pings antwortet (Fritz!Box: Internet → Freigaben → \"Auf Ping reagieren\" aktivieren)",
199199
"bqm_setup_step_3": "Gehe zu <b>thinkbroadband.com/broadband/monitoring</b>, erstelle ein kostenloses Konto und registriere deine DynDNS-Adresse",
200200
"bqm_setup_step_4": "Sobald das Monitoring aktiv ist, kopiere deine <b>Share-URL</b> (endet auf .png) und füge sie in DOCSight unter <b>Einstellungen → BQM Share URL</b> ein",
201-
"report_language": "Berichtssprache"
201+
"report_language": "Berichtssprache",
202+
"report_customer_hint": "Optional: Deine Daten für das Beschwerdeschreiben",
203+
"report_name": "Dein Name",
204+
"report_customer_number": "Kunden-/Vertragsnummer",
205+
"report_address": "Deine Adresse",
206+
"report_edit_hint": "Prüfe und bearbeite das Beschwerdeschreiben. Kopiere es zum Versand per E-Mail oder lade den technischen Bericht als PDF-Anhang herunter.",
207+
"report_generate_letter": "Schreiben erstellen",
208+
"report_copy_letter": "Schreiben kopieren",
209+
"report_download_pdf": "Technischen Bericht (PDF) laden"
202210
}

app/i18n/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,5 +198,13 @@
198198
"bqm_setup_step_2": "Enable <b>WAN ping/ICMP</b> on your router so it responds to external pings (Fritz!Box: Internet → Permit Access → tick \"Respond to ping\")",
199199
"bqm_setup_step_3": "Go to <b>thinkbroadband.com/broadband/monitoring</b>, create a free account, and register your DynDNS address",
200200
"bqm_setup_step_4": "Once monitoring is active, copy your <b>Share URL</b> (ends in .png) and paste it in DOCSight <b>Settings → BQM Share URL</b>",
201-
"report_language": "Report language"
201+
"report_language": "Report language",
202+
"report_customer_hint": "Optional: Add your details for the complaint letter",
203+
"report_name": "Your name",
204+
"report_customer_number": "Customer/contract number",
205+
"report_address": "Your address",
206+
"report_edit_hint": "Review and edit the complaint letter below. Copy it to send via email, or download the full technical report as PDF attachment.",
207+
"report_generate_letter": "Generate Letter",
208+
"report_copy_letter": "Copy Letter",
209+
"report_download_pdf": "Download Technical Report (PDF)"
202210
}

app/i18n/es.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,5 +193,13 @@
193193
"bqm_setup_step_2": "Habilita el <b>ping WAN/ICMP</b> en tu router para que responda a pings externos",
194194
"bqm_setup_step_3": "Ve a <b>thinkbroadband.com/broadband/monitoring</b>, crea una cuenta gratuita y registra tu dirección DynDNS",
195195
"bqm_setup_step_4": "Una vez activo el monitoreo, copia tu <b>URL de compartir</b> (termina en .png) y pégala en DOCSight en <b>Configuración → BQM Share URL</b>",
196-
"report_language": "Idioma del informe"
196+
"report_language": "Idioma del informe",
197+
"report_customer_hint": "Opcional: añade tus datos para la carta de reclamación",
198+
"report_name": "Tu nombre",
199+
"report_customer_number": "Número de cliente/contrato",
200+
"report_address": "Tu dirección",
201+
"report_edit_hint": "Revisa y edita la carta de reclamación. Cópiala para enviarla por correo o descarga el informe técnico en PDF.",
202+
"report_generate_letter": "Generar carta",
203+
"report_copy_letter": "Copiar carta",
204+
"report_download_pdf": "Descargar informe técnico (PDF)"
197205
}

app/i18n/fr.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,13 @@
190190
"bqm_setup_step_2": "Activez le <b>ping WAN/ICMP</b> sur votre routeur pour qu'il réponde aux pings externes",
191191
"bqm_setup_step_3": "Allez sur <b>thinkbroadband.com/broadband/monitoring</b>, créez un compte gratuit et enregistrez votre adresse DynDNS",
192192
"bqm_setup_step_4": "Une fois le monitoring actif, copiez votre <b>URL de partage</b> (se termine en .png) et collez-la dans DOCSight sous <b>Paramètres → BQM Share URL</b>",
193-
"report_language": "Langue du rapport"
193+
"report_language": "Langue du rapport",
194+
"report_customer_hint": "Optionnel : ajoutez vos coordonnées pour la lettre de réclamation",
195+
"report_name": "Votre nom",
196+
"report_customer_number": "Numéro client/contrat",
197+
"report_address": "Votre adresse",
198+
"report_edit_hint": "Vérifiez et modifiez la lettre de réclamation. Copiez-la pour l'envoyer par e-mail ou téléchargez le rapport technique en PDF.",
199+
"report_generate_letter": "Générer la lettre",
200+
"report_copy_letter": "Copier la lettre",
201+
"report_download_pdf": "Télécharger le rapport technique (PDF)"
194202
}

app/report.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"The full monitoring data is attached to this report. I reserve the right to escalate this matter "
9898
"to the Bundesnetzagentur (Federal Network Agency) if the issue is not resolved within a reasonable timeframe."
9999
),
100+
"complaint_closing_label": "Sincerely,",
100101
"complaint_closing": "Sincerely,\n[Your Name]\n[Customer Number]\n[Address]",
101102
"complaint_short_subject": "Subject: DOCSIS Signal Quality Issues",
102103
"complaint_short_greeting": "Dear Technical Support,",
@@ -177,6 +178,7 @@
177178
"diese Angelegenheit an die Bundesnetzagentur weiterzuleiten, sofern das Problem nicht "
178179
"innerhalb einer angemessenen Frist behoben wird."
179180
),
181+
"complaint_closing_label": "Mit freundlichen Grüßen,",
180182
"complaint_closing": "Mit freundlichen Grüßen,\n[Ihr Name]\n[Kundennummer]\n[Adresse]",
181183
"complaint_short_subject": "Betreff: DOCSIS-Signalqualitätsprobleme",
182184
"complaint_short_greeting": "Sehr geehrte Damen und Herren,",
@@ -257,6 +259,7 @@
257259
"l'ARCEP (Autorité de régulation des communications électroniques et des postes) si le problème "
258260
"n'est pas résolu dans un délai raisonnable."
259261
),
262+
"complaint_closing_label": "Veuillez agréer mes salutations distinguées,",
260263
"complaint_closing": "Veuillez agréer mes salutations distinguées,\n[Votre nom]\n[Numéro client]\n[Adresse]",
261264
"complaint_short_subject": "Objet : Problèmes de qualité du signal DOCSIS",
262265
"complaint_short_greeting": "Madame, Monsieur,",
@@ -337,6 +340,7 @@
337340
"este asunto a la Secretaría de Estado de Telecomunicaciones e Infraestructuras Digitales "
338341
"si el problema no se resuelve en un plazo razonable."
339342
),
343+
"complaint_closing_label": "Atentamente,",
340344
"complaint_closing": "Atentamente,\n[Su nombre]\n[Número de cliente]\n[Dirección]",
341345
"complaint_short_subject": "Asunto: Problemas de calidad de señal DOCSIS",
342346
"complaint_short_greeting": "Estimado servicio técnico,",
@@ -649,3 +653,69 @@ def generate_report(snapshots, current_analysis, config=None, connection_info=No
649653
buf = io.BytesIO()
650654
pdf.output(buf)
651655
return buf.getvalue()
656+
657+
658+
def generate_complaint_text(snapshots, config=None, connection_info=None, lang="en",
659+
customer_name="", customer_number="", customer_address=""):
660+
"""Generate ISP complaint letter as plain text.
661+
662+
Args:
663+
snapshots: List of snapshot dicts
664+
config: Config dict (isp_name, etc.)
665+
connection_info: Connection info dict
666+
lang: Language code
667+
customer_name: Customer name for letter
668+
customer_number: Customer/contract number
669+
customer_address: Customer address
670+
671+
Returns:
672+
str: Complaint letter text
673+
"""
674+
config = config or {}
675+
s = REPORT_STRINGS.get(lang, REPORT_STRINGS["en"])
676+
isp = config.get("isp_name", "Unknown ISP")
677+
678+
# Build closing with actual customer data
679+
closing_lines = []
680+
closing_lines.append(s.get("complaint_closing_label", "Sincerely,"))
681+
closing_lines.append(customer_name if customer_name else "[Your Name]")
682+
if customer_number:
683+
closing_lines.append(customer_number)
684+
else:
685+
closing_lines.append("[Customer Number]")
686+
if customer_address:
687+
closing_lines.append(customer_address)
688+
else:
689+
closing_lines.append("[Address]")
690+
closing = "\n".join(closing_lines)
691+
692+
if snapshots:
693+
worst = _compute_worst_values(snapshots)
694+
start = snapshots[0]["timestamp"][:10]
695+
end = snapshots[-1]["timestamp"][:10]
696+
poor_pct = round(worst['health_poor_count'] / max(worst['total_snapshots'], 1) * 100)
697+
return (
698+
f"{s['complaint_subject']}\n\n"
699+
f"{s['complaint_greeting'].format(isp=isp)}\n\n"
700+
f"{s['complaint_body'].format(count=len(snapshots), start=start, end=end)}\n\n"
701+
f"{s['complaint_findings']}\n"
702+
f"- {s['complaint_poor_rate'].format(poor=worst['health_poor_count'], total=worst['total_snapshots'], pct=poor_pct)}\n"
703+
f"- {s['complaint_ds_power'].format(val=worst['ds_power_max'], thresh=THRESHOLDS['ds_power']['warn'])}\n"
704+
f"- {s['complaint_us_power'].format(val=worst['us_power_max'], thresh=THRESHOLDS['us_power']['warn'])}\n"
705+
f"- {s['complaint_snr'].format(val=worst['ds_snr_min'], thresh=THRESHOLDS['snr']['warn'])}\n"
706+
f"- {s['complaint_uncorr'].format(val='{:,}'.format(worst['ds_uncorrectable_max']))}\n\n"
707+
f"{s['complaint_exceed']}\n\n"
708+
f"{s['complaint_request']}\n"
709+
f"1. {s['complaint_req1']}\n"
710+
f"2. {s['complaint_req2']}\n"
711+
f"3. {s['complaint_req3']}\n\n"
712+
f"{s['complaint_escalation']}\n\n"
713+
f"{closing}"
714+
)
715+
else:
716+
return (
717+
f"{s['complaint_short_subject']}\n\n"
718+
f"{s['complaint_short_greeting']}\n\n"
719+
f"{s['complaint_short_body']}\n\n"
720+
f"{closing}"
721+
)

app/templates/index.html

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -928,37 +928,53 @@ <h2>&#128200; {{ t.bqm_setup_title }}</h2>
928928

929929
<!-- Report Modal -->
930930
<div class="modal-overlay" id="report-modal" onclick="if(event.target===this)closeReportModal()">
931-
<div class="modal">
931+
<div class="modal" style="max-width:800px;">
932932
<div class="modal-header">
933933
<h2>{{ t.incident_report }}</h2>
934934
<button class="modal-close" onclick="closeReportModal()">&times;</button>
935935
</div>
936936
<p class="modal-hint">{{ t.report_description }}</p>
937-
<div class="modal-body" style="padding: 10px 20px;">
938-
<div style="display:flex; gap:20px; align-items:center; flex-wrap:wrap;">
939-
<div>
940-
<label for="report-days" style="font-size:14px;">{{ t.report_days }}:</label><br>
941-
<select id="report-days" style="margin: 8px 0; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text);">
942-
<option value="1">1</option>
943-
<option value="3">3</option>
944-
<option value="7" selected>7</option>
945-
<option value="14">14</option>
946-
<option value="30">30</option>
947-
</select>
937+
<div class="modal-body" style="padding: 10px 0;">
938+
<!-- Step 1: Settings & Customer Info -->
939+
<div id="report-step1">
940+
<div style="display:flex; gap:16px; flex-wrap:wrap; margin-bottom:14px;">
941+
<div>
942+
<label style="font-size:13px; color:var(--muted);">{{ t.report_days }}</label><br>
943+
<select id="report-days" style="margin-top:4px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); background:var(--bg); color:var(--text);">
944+
<option value="1">1</option><option value="3">3</option>
945+
<option value="7" selected>7</option><option value="14">14</option>
946+
<option value="30">30</option>
947+
</select>
948+
</div>
949+
<div>
950+
<label style="font-size:13px; color:var(--muted);">{{ t.report_language }}</label><br>
951+
<select id="report-lang" style="margin-top:4px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); background:var(--bg); color:var(--text);">
952+
{% for code, name in languages.items() %}
953+
<option value="{{ code }}" {% if code == lang %}selected{% endif %}>{{ lang_flags.get(code, '') }} {{ name }}</option>
954+
{% endfor %}
955+
</select>
956+
</div>
948957
</div>
949-
<div>
950-
<label for="report-lang" style="font-size:14px;">{{ t.report_language }}:</label><br>
951-
<select id="report-lang" style="margin: 8px 0; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text);">
952-
{% for code, name in languages.items() %}
953-
<option value="{{ code }}" {% if code == lang %}selected{% endif %}>{{ lang_flags.get(code, '') }} {{ name }}</option>
954-
{% endfor %}
955-
</select>
958+
<div style="background:var(--bg); border-radius:8px; padding:14px; margin-bottom:10px;">
959+
<p style="font-size:13px; color:var(--muted); margin-bottom:10px;">{{ t.report_customer_hint }}</p>
960+
<div style="display:flex; flex-direction:column; gap:8px;">
961+
<input id="report-name" type="text" placeholder="{{ t.report_name }}" style="padding:8px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-family:inherit;">
962+
<input id="report-number" type="text" placeholder="{{ t.report_customer_number }}" style="padding:8px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-family:inherit;">
963+
<input id="report-address" type="text" placeholder="{{ t.report_address }}" style="padding:8px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-family:inherit;">
964+
</div>
956965
</div>
957966
</div>
967+
<!-- Step 2: Generated complaint text (hidden initially) -->
968+
<div id="report-step2" style="display:none;">
969+
<p style="font-size:13px; color:var(--muted); margin-bottom:8px;">{{ t.report_edit_hint }}</p>
970+
<textarea id="report-complaint-text" style="width:100%; min-height:320px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:12px; font-size:0.85em; font-family:'Consolas','Monaco',monospace; resize:vertical; line-height:1.5;"></textarea>
971+
</div>
958972
</div>
959973
<div class="modal-footer">
960974
<button class="btn btn-muted" onclick="closeReportModal()">{{ t.close }}</button>
961-
<button class="btn btn-accent" onclick="downloadReport()">&#128203; {{ t.generate_report }}</button>
975+
<button class="btn btn-accent" id="report-generate-btn" onclick="generateComplaint()">&#9998; {{ t.report_generate_letter }}</button>
976+
<button class="btn btn-accent" id="report-copy-btn" style="display:none;" onclick="copyComplaint()">&#128203; {{ t.report_copy_letter }}</button>
977+
<button class="btn btn-accent" id="report-pdf-btn" style="display:none;" onclick="downloadReport()">&#128196; {{ t.report_download_pdf }}</button>
962978
</div>
963979
</div>
964980
</div>
@@ -1454,12 +1470,54 @@ <h2>{{ t.export_title }}</h2>
14541470
}
14551471
function closeReportModal() {
14561472
document.getElementById('report-modal').classList.remove('open');
1473+
// Reset to step 1
1474+
document.getElementById('report-step1').style.display = '';
1475+
document.getElementById('report-step2').style.display = 'none';
1476+
document.getElementById('report-generate-btn').style.display = '';
1477+
document.getElementById('report-copy-btn').style.display = 'none';
1478+
document.getElementById('report-pdf-btn').style.display = 'none';
1479+
}
1480+
function generateComplaint() {
1481+
var days = document.getElementById('report-days').value;
1482+
var lang = document.getElementById('report-lang').value;
1483+
var name = encodeURIComponent(document.getElementById('report-name').value);
1484+
var number = encodeURIComponent(document.getElementById('report-number').value);
1485+
var address = encodeURIComponent(document.getElementById('report-address').value);
1486+
var btn = document.getElementById('report-generate-btn');
1487+
btn.disabled = true;
1488+
btn.textContent = '...';
1489+
fetch('/api/complaint?days=' + days + '&lang=' + lang + '&name=' + name + '&number=' + number + '&address=' + address)
1490+
.then(function(r) { return r.json(); })
1491+
.then(function(data) {
1492+
document.getElementById('report-complaint-text').value = data.text;
1493+
document.getElementById('report-step1').style.display = 'none';
1494+
document.getElementById('report-step2').style.display = 'block';
1495+
document.getElementById('report-generate-btn').style.display = 'none';
1496+
document.getElementById('report-copy-btn').style.display = '';
1497+
document.getElementById('report-pdf-btn').style.display = '';
1498+
})
1499+
.catch(function(e) { alert('Error: ' + e); })
1500+
.finally(function() { btn.disabled = false; btn.textContent = '\u270E Generate Letter'; });
1501+
}
1502+
function copyComplaint() {
1503+
var textarea = document.getElementById('report-complaint-text');
1504+
var btn = document.getElementById('report-copy-btn');
1505+
textarea.select();
1506+
textarea.setSelectionRange(0, textarea.value.length);
1507+
if (navigator.clipboard && navigator.clipboard.writeText) {
1508+
navigator.clipboard.writeText(textarea.value).then(function() {
1509+
var orig = btn.innerHTML;
1510+
btn.innerHTML = '&#10003; Copied!';
1511+
setTimeout(function() { btn.innerHTML = orig; }, 2000);
1512+
});
1513+
} else {
1514+
document.execCommand('copy');
1515+
}
14571516
}
14581517
function downloadReport() {
14591518
var days = document.getElementById('report-days').value;
14601519
var lang = document.getElementById('report-lang').value;
14611520
window.location.href = '/api/report?days=' + days + '&lang=' + lang;
1462-
closeReportModal();
14631521
}
14641522
function copyExport() {
14651523
var T = {{ t|tojson }};

app/web.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,44 @@ def api_report():
567567
return response
568568

569569

570+
@app.route("/api/complaint")
571+
@_require_auth
572+
def api_complaint():
573+
"""Generate ISP complaint letter as text."""
574+
from .report import generate_complaint_text
575+
576+
analysis = _state.get("analysis")
577+
if not analysis:
578+
return jsonify({"error": "No data available"}), 404
579+
580+
days = request.args.get("days", 7, type=int)
581+
days = max(1, min(days, 90))
582+
end_ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
583+
start_ts = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
584+
585+
snapshots = []
586+
if _storage:
587+
snapshots = _storage.get_range_data(start_ts, end_ts)
588+
589+
config = {}
590+
if _config_manager:
591+
config = {
592+
"isp_name": _config_manager.get("isp_name", ""),
593+
"modem_type": _config_manager.get("modem_type", ""),
594+
}
595+
596+
lang = request.args.get("lang", _get_lang())
597+
customer_name = request.args.get("name", "")
598+
customer_number = request.args.get("number", "")
599+
customer_address = request.args.get("address", "")
600+
601+
text = generate_complaint_text(
602+
snapshots, config, None, lang,
603+
customer_name, customer_number, customer_address
604+
)
605+
return jsonify({"text": text, "lang": lang})
606+
607+
570608
@app.route("/health")
571609
def health():
572610
"""Simple health check endpoint."""

0 commit comments

Comments
 (0)