Auto-commit local changes before build (2026-01-03 18:22:48)

This commit is contained in:
Ivo Oskamp 2026-01-03 18:22:48 +01:00
parent 46e93b3f01
commit 2710a140ad
5 changed files with 173 additions and 18 deletions

View File

@ -1 +1 @@
v20260103-12-reports-columns-selector-init-fix v20260103-13-reports-edit-and-view-raw-fix

View File

@ -21,6 +21,17 @@ def _safe_json_list(value):
return [] return []
def _safe_json_dict(value):
if not value:
return {}
if isinstance(value, dict):
return value
try:
return json.loads(value)
except Exception:
return {}
def _build_report_item(r): def _build_report_item(r):
return { return {
"id": int(r.id), "id": int(r.id),
@ -33,6 +44,7 @@ def _build_report_item(r):
"period_start": r.period_start.isoformat() if getattr(r, "period_start", None) else "", "period_start": r.period_start.isoformat() if getattr(r, "period_start", None) else "",
"period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "", "period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "",
"schedule": r.schedule or "", "schedule": r.schedule or "",
"report_config": _safe_json_dict(getattr(r, "report_config", None)),
"created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "", "created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "",
} }
@ -72,4 +84,36 @@ def reports_new():
) )
customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers] customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers]
return render_template("main/reports_new.html", initial_customers=customer_items, columns_meta=build_report_columns_meta()) return render_template(
"main/reports_new.html",
initial_customers=customer_items,
columns_meta=build_report_columns_meta(),
is_edit=False,
initial_report=None,
)
@main_bp.route("/reports/<int:report_id>/edit")
@login_required
def reports_edit(report_id: int):
# Editing reports is limited to the same roles that can create them.
if get_active_role() not in ("admin", "operator", "reporter"):
return abort(403)
r = ReportDefinition.query.get_or_404(report_id)
customers = (
db.session.query(Customer)
.filter(Customer.active.is_(True))
.order_by(Customer.name.asc())
.all()
)
customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers]
return render_template(
"main/reports_new.html",
initial_customers=customer_items,
columns_meta=build_report_columns_meta(),
is_edit=True,
initial_report=_build_report_item(r),
)

View File

@ -40,7 +40,7 @@
{% for item in initial_reports %} {% for item in initial_reports %}
<tr> <tr>
<td><strong>{{ item.name }}</strong><div class="text-muted small">{{ item.description }}</div></td> <td><strong>{{ item.name }}</strong><div class="text-muted small">{{ item.description }}</div></td>
<td class="text-muted small">{{ item.created_at.replace('T',' ') if item.created_at else '' }}</td> <td class="text-muted small">{{ item.report_type }}</td>
<td class="text-muted small"> <td class="text-muted small">
{% if item.period_start or item.period_end %} {% if item.period_start or item.period_end %}
{{ item.period_start.replace('T',' ') if item.period_start else '' }} → {{ item.period_end.replace('T',' ') if item.period_end else '' }} {{ item.period_start.replace('T',' ') if item.period_start else '' }} → {{ item.period_end.replace('T',' ') if item.period_end else '' }}
@ -50,6 +50,7 @@
</td> </td>
<td><span class="badge text-bg-light border">{{ item.output_format }}</span></td> <td><span class="badge text-bg-light border">{{ item.output_format }}</span></td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('main.reports_edit', report_id=item.id) }}">Edit</a>
<button type="button" class="btn btn-sm btn-outline-primary rep-generate-btn" data-id="{{ item.id }}">Generate</button> <button type="button" class="btn btn-sm btn-outline-primary rep-generate-btn" data-id="{{ item.id }}">Generate</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 rep-view-btn" data-id="{{ item.id }}">View raw</button> <button type="button" class="btn btn-sm btn-outline-secondary ms-1 rep-view-btn" data-id="{{ item.id }}">View raw</button>
<a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export.csv" target="_blank" rel="noopener">Download</a> <a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export.csv" target="_blank" rel="noopener">Download</a>
@ -64,7 +65,7 @@
<td colspan="5" class="text-center text-muted py-4">No reports found.</td> <td colspan="5" class="text-center text-muted py-4">No reports found.</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody>> </tbody>
</table> </table>
</div> </div>
</div> </div>
@ -271,6 +272,28 @@
} }
} }
function setRawDownloadLink() {
var btn = qs('rep_raw_download_btn');
if (!btn) return;
if (!rawReportId) {
btn.setAttribute('href', '#');
btn.classList.add('disabled');
return;
}
btn.classList.remove('disabled');
btn.setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView);
}
function updateRawMeta(total) {
var t = parseInt(total || 0, 10) || 0;
var start = t ? (rawOffset + 1) : 0;
var end = t ? Math.min(rawOffset + rawLimit, t) : 0;
var label = (rawView === 'snapshot') ? 'Snapshot' : 'Summary';
qs('rep_raw_meta').textContent = label + ' · Rows ' + start + '-' + end + ' of ' + t;
qs('rep_raw_prev_btn').disabled = (rawOffset <= 0);
qs('rep_raw_next_btn').disabled = ((rawOffset + rawLimit) >= t);
}
function renderRawTable(view, items) { function renderRawTable(view, items) {
var thead = qs('rep_raw_thead'); var thead = qs('rep_raw_thead');
var tbody = qs('rep_raw_tbody'); var tbody = qs('rep_raw_tbody');
@ -368,6 +391,7 @@ function loadRawData() {
'<td class="text-muted small">' + period + '</td>' + '<td class="text-muted small">' + period + '</td>' +
'<td><span class="badge text-bg-light border">' + fmt + '</span></td>' + '<td><span class="badge text-bg-light border">' + fmt + '</span></td>' +
'<td class="text-end">' + '<td class="text-end">' +
'<a class="btn btn-sm btn-outline-secondary me-1" href="/reports/' + item.id + '/edit">Edit</a>' +
'<button type="button" class="btn btn-sm btn-outline-primary me-1 rep-generate-btn" data-id="' + item.id + '">Generate</button>' + '<button type="button" class="btn btn-sm btn-outline-primary me-1 rep-generate-btn" data-id="' + item.id + '">Generate</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary me-1 rep-view-btn" data-id="' + item.id + '">View raw</button>' + '<button type="button" class="btn btn-sm btn-outline-secondary me-1 rep-view-btn" data-id="' + item.id + '">View raw</button>' +
'<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export.csv" target="_blank" rel="noopener">Download</a>' + '<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export.csv" target="_blank" rel="noopener">Download</a>' +

View File

@ -3,8 +3,8 @@
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3"> <div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
<div> <div>
<h2 class="mb-1">New report</h2> <h2 class="mb-1">{{ 'Edit report' if is_edit else 'New report' }}</h2>
<div class="text-muted">Create a one-time report definition. Generate output from the Reports overview.</div> <div class="text-muted">{{ 'Update this report definition. Generate output from the Reports overview.' if is_edit else 'Create a one-time report definition. Generate output from the Reports overview.' }}</div>
</div> </div>
<div class="mt-2 mt-md-0"> <div class="mt-2 mt-md-0">
<a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Back</a> <a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Back</a>
@ -157,7 +157,7 @@
<hr class="my-4" /> <hr class="my-4" />
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary" id="rep_create_btn">Create report</button> <button type="button" class="btn btn-primary" id="rep_create_btn">{{ 'Save changes' if is_edit else 'Create report' }}</button>
<a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Cancel</a> <a class="btn btn-outline-secondary" href="{{ url_for('main.reports') }}">Cancel</a>
</div> </div>
</div> </div>
@ -199,6 +199,8 @@
<script> <script>
window.__reportColumnsMeta = {{ columns_meta|tojson }}; window.__reportColumnsMeta = {{ columns_meta|tojson }};
window.__initialCustomers = {{ initial_customers|tojson }}; window.__initialCustomers = {{ initial_customers|tojson }};
window.__isEdit = {{ 'true' if is_edit else 'false' }};
window.__initialReport = {{ initial_report|tojson }};
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
function qs(id) { return document.getElementById(id); } function qs(id) { return document.getElementById(id); }
@ -215,12 +217,24 @@
el.textContent = ''; el.textContent = '';
} }
var isEdit = !!window.__isEdit;
var initialReport = window.__initialReport || null;
var editReportId = (initialReport && initialReport.id) ? initialReport.id : null;
// --- Report content / column selector --- // --- Report content / column selector ---
var repColsView = 'summary'; var repColsView = 'summary';
var repColsMeta = window.__reportColumnsMeta || null; var repColsMeta = window.__reportColumnsMeta || null;
var repColsSelected = { summary: [], snapshot: [] }; var repColsSelected = { summary: [], snapshot: [] };
if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.columns) {
var cols = initialReport.report_config.columns;
repColsSelected = {
summary: Array.isArray(cols.summary) ? cols.summary.slice() : [],
snapshot: Array.isArray(cols.snapshot) ? cols.snapshot.slice() : [],
};
}
function colsHintText(viewKey) { function colsHintText(viewKey) {
return viewKey === 'snapshot' return viewKey === 'snapshot'
? 'Select columns for the snapshot view.' ? 'Select columns for the snapshot view.'
@ -482,6 +496,15 @@
qs(prefix + '_time').value = pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes()); qs(prefix + '_time').value = pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes());
} }
function setDateTimeFromIso(prefix, iso) {
var s = (iso || '').trim();
if (!s) return;
var m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:T|\s)(\d{2}):(\d{2})/);
if (!m) return;
qs(prefix + '_date').value = m[1] + '-' + m[2] + '-' + m[3];
qs(prefix + '_time').value = m[4] + ':' + m[5];
}
function buildIso(dateStr, timeStr, fallbackTime) { function buildIso(dateStr, timeStr, fallbackTime) {
var d = (dateStr || '').trim(); var d = (dateStr || '').trim();
var t = (timeStr || '').trim() || (fallbackTime || '00:00'); var t = (timeStr || '').trim() || (fallbackTime || '00:00');
@ -529,10 +552,32 @@
qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple'); qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple');
} }
function applyCustomerSelection() {
if (!isEdit || !initialReport) return;
var scope = (initialReport.customer_scope || 'all');
var ids = initialReport.customer_ids || [];
if (scope === 'single') {
var singleSel = qs('rep_customer_single');
if (singleSel && ids.length === 1) {
singleSel.value = String(ids[0]);
}
} else if (scope === 'multiple') {
var multiSel = qs('rep_customer_multiple');
if (multiSel && Array.isArray(multiSel.options)) {
for (var i = 0; i < multiSel.options.length; i++) {
var opt = multiSel.options[i];
opt.selected = (ids.indexOf(parseInt(opt.value, 10)) >= 0);
}
}
}
}
function loadCustomers() { function loadCustomers() {
var initialCustomers = window.__initialCustomers || null; var initialCustomers = window.__initialCustomers || null;
if (initialCustomers && Array.isArray(initialCustomers) && initialCustomers.length) { if (initialCustomers && Array.isArray(initialCustomers) && initialCustomers.length) {
// Already rendered server-side. Keep the selects usable even if API calls fail. // Already rendered server-side. Keep the selects usable even if API calls fail.
applyCustomerSelection();
return; return;
} }
qs('rep_customer_single').innerHTML = '<option value="" selected>Loading…</option>'; qs('rep_customer_single').innerHTML = '<option value="" selected>Loading…</option>';
@ -559,12 +604,33 @@
opt2.textContent = c.name || ('Customer ' + c.id); opt2.textContent = c.name || ('Customer ' + c.id);
qs('rep_customer_multiple').appendChild(opt2); qs('rep_customer_multiple').appendChild(opt2);
}); });
applyCustomerSelection();
}) })
.catch(function () { .catch(function () {
qs('rep_customer_single').innerHTML = '<option value="" selected>Failed to load customers</option>'; qs('rep_customer_single').innerHTML = '<option value="" selected>Failed to load customers</option>';
}); });
} }
function applyInitialReport() {
if (!isEdit || !initialReport) return;
qs('rep_name').value = initialReport.name || '';
qs('rep_description').value = initialReport.description || '';
qs('rep_output_format').value = (initialReport.output_format || 'csv');
setDateTimeFromIso('rep_start', initialReport.period_start || '');
setDateTimeFromIso('rep_end', initialReport.period_end || '');
// scope + customer selections
var scope = (initialReport.customer_scope || 'all');
qs('rep_scope_single').checked = (scope === 'single');
qs('rep_scope_multiple').checked = (scope === 'multiple');
qs('rep_scope_all').checked = (scope === 'all');
updateScopeUi();
applyCustomerSelection();
}
function validate(payload) { function validate(payload) {
if (!payload.name) return 'Report name is required.'; if (!payload.name) return 'Report name is required.';
if (!payload.period_start || !payload.period_end) return 'Start and end period are required.'; if (!payload.period_start || !payload.period_end) return 'Start and end period are required.';
@ -582,6 +648,11 @@
function createReport() { function createReport() {
clearError(); clearError();
if (isEdit && !editReportId) {
showError('Missing report id.');
return;
}
var scope = selectedScope(); var scope = selectedScope();
var customerIds = []; var customerIds = [];
if (scope === 'single') { if (scope === 'single') {
@ -619,10 +690,13 @@
var btn = qs('rep_create_btn'); var btn = qs('rep_create_btn');
var oldText = btn.textContent; var oldText = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Creating…'; btn.textContent = (isEdit ? 'Saving…' : 'Creating…');
fetch('/api/reports', { var url = isEdit ? ('/api/reports/' + editReportId) : '/api/reports';
method: 'POST', var method = isEdit ? 'PUT' : 'POST';
fetch(url, {
method: method,
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@ -632,7 +706,7 @@
btn.disabled = false; btn.disabled = false;
btn.textContent = oldText; btn.textContent = oldText;
if (!res.ok) { if (!res.ok) {
showError((res.json && res.json.error) ? res.json.error : 'Create failed.'); showError((res.json && res.json.error) ? res.json.error : (isEdit ? 'Save failed.' : 'Create failed.'));
return; return;
} }
window.location.href = '{{ url_for('main.reports') }}'; window.location.href = '{{ url_for('main.reports') }}';
@ -640,16 +714,20 @@
.catch(function () { .catch(function () {
btn.disabled = false; btn.disabled = false;
btn.textContent = oldText; btn.textContent = oldText;
showError('Create failed.'); showError(isEdit ? 'Save failed.' : 'Create failed.');
}); });
} }
// Defaults // Defaults / initial values
if (isEdit) {
applyInitialReport();
} else {
var now = todayUtc(); var now = todayUtc();
var end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), 0)); var end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), 0));
var start = new Date(end.getTime() - (7 * 24 * 60 * 60 * 1000)); var start = new Date(end.getTime() - (7 * 24 * 60 * 60 * 1000));
setDateTime('rep_start', start); setDateTime('rep_start', start);
setDateTime('rep_end', end); setDateTime('rep_end', end);
}
document.querySelectorAll('input[name="rep_scope"]').forEach(function (r) { document.querySelectorAll('input[name="rep_scope"]').forEach(function (r) {
r.addEventListener('change', updateScopeUi); r.addEventListener('change', updateScopeUi);

View File

@ -107,6 +107,15 @@
- Fixed /api/reports/columns to always return the column metadata payload (removed incorrect indentation that prevented a response for authorized users). - Fixed /api/reports/columns to always return the column metadata payload (removed incorrect indentation that prevented a response for authorized users).
- Ensured the “New report” page always initializes the column selector by providing the initial customers payload to the frontend (prevents JS initialization issues that could stop the column lists from rendering). - Ensured the “New report” page always initializes the column selector by providing the initial customers payload to the frontend (prevents JS initialization issues that could stop the column lists from rendering).
---
## v20260103-13-reports-edit-and-view-raw-fix
- Added the ability to edit existing reports via a dedicated edit view.
- Reused the report configuration form for both creating and editing reports.
- Fixed the "View raw" functionality so raw report data can be viewed again.
- Resolved an HTML rendering issue causing an extra ">" character above the Name column.
- Improved the reports list to correctly display the report type instead of incorrect metadata.
================================================================================================================================================ ================================================================================================================================================