diff --git a/.last-branch b/.last-branch index bcc333e..c97e656 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-07-feedback-open-reply +v20260106-08-ticket-code-input-disable-edit diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 7242eff..fd3ad03 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -1,5 +1,6 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_ui_timezone_name, _next_ticket_code, _to_amsterdam_date +from .routes_shared import _format_datetime, _get_ui_timezone_name, _to_amsterdam_date +import re @main_bp.route("/api/job-runs//alerts") @login_required @@ -194,10 +195,21 @@ def api_tickets(): job = Job.query.get(run.job_id) if run else None now = datetime.utcnow() - code = _next_ticket_code(now) + ticket_code = (payload.get("ticket_code") or "").strip().upper() + + if not ticket_code: + return jsonify({"status": "error", "message": "ticket_code is required."}), 400 + + # Validate format: TYYYYMMDD.#### + if not re.match(r"^T\d{8}\.\d{4}$", ticket_code): + return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 + + # Ensure uniqueness + if Ticket.query.filter_by(ticket_code=ticket_code).first(): + return jsonify({"status": "error", "message": "ticket_code already exists."}), 409 ticket = Ticket( - ticket_code=code, + ticket_code=ticket_code, title=None, description=description, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), @@ -250,21 +262,8 @@ def api_tickets(): @login_required @roles_required("admin", "operator", "viewer") def api_ticket_update(ticket_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - ticket = Ticket.query.get_or_404(ticket_id) - payload = request.get_json(silent=True) or {} - if "description" in payload: - ticket.description = (payload.get("description") or "").strip() or None - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update ticket."}), 500 - - return jsonify({"status": "ok"}) + # Editing tickets is not allowed. Resolve the old ticket and create a new one instead. + return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405 @main_bp.route("/api/tickets//resolve", methods=["POST"]) @@ -420,21 +419,8 @@ def api_remarks(): @login_required @roles_required("admin", "operator", "viewer") def api_remark_update(remark_id: int): - if get_active_role() not in ("admin", "operator"): - return jsonify({"status": "error", "message": "Forbidden."}), 403 - - remark = Remark.query.get_or_404(remark_id) - payload = request.get_json(silent=True) or {} - if "body" in payload: - remark.body = (payload.get("body") or "").strip() or "" - - try: - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to update remark."}), 500 - - return jsonify({"status": "ok"}) + # Editing remarks is not allowed. Resolve the old remark and create a new one instead. + return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405 @main_bp.route("/api/remarks//resolve", methods=["POST"]) @@ -489,4 +475,4 @@ def api_remark_link_run(remark_id: int): db.session.rollback() return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500 - return jsonify({"status": "ok"}) + return jsonify({"status": "ok"}) \ No newline at end of file diff --git a/containers/backupchecks/src/backend/app/main/routes_remarks.py b/containers/backupchecks/src/backend/app/main/routes_remarks.py index 2ceab03..4c4f564 100644 --- a/containers/backupchecks/src/backend/app/main/routes_remarks.py +++ b/containers/backupchecks/src/backend/app/main/routes_remarks.py @@ -1,22 +1,13 @@ from .routes_shared import * # noqa: F401,F403 from .routes_shared import _format_datetime -@main_bp.route("/remarks/", methods=["GET", "POST"]) +@main_bp.route("/remarks/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def remark_detail(remark_id: int): remark = Remark.query.get_or_404(remark_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - remark.body = (request.form.get("body") or "").strip() or "" - try: - db.session.commit() - flash("Remark updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update remark: {exc}", "danger") + # Remark editing is disabled. Resolve the old remark and create a new one instead. return redirect(url_for("main.remark_detail", remark_id=remark.id)) scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all() diff --git a/containers/backupchecks/src/backend/app/main/routes_tickets.py b/containers/backupchecks/src/backend/app/main/routes_tickets.py index a1cc6f1..3f83bef 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -270,22 +270,13 @@ def tickets_page(): ) -@main_bp.route("/tickets/", methods=["GET", "POST"]) +@main_bp.route("/tickets/", methods=["GET"]) @login_required @roles_required("admin", "operator", "viewer") def ticket_detail(ticket_id: int): ticket = Ticket.query.get_or_404(ticket_id) - if request.method == "POST": - if get_active_role() not in ("admin", "operator"): - abort(403) - ticket.description = (request.form.get("description") or "").strip() or None - try: - db.session.commit() - flash("Ticket updated.", "success") - except Exception as exc: - db.session.rollback() - flash(f"Failed to update ticket: {exc}", "danger") + # Ticket editing is disabled. Resolve the old ticket and create a new one instead. return redirect(url_for("main.ticket_detail", ticket_id=ticket.id)) # Scopes diff --git a/containers/backupchecks/src/templates/main/daily_jobs.html b/containers/backupchecks/src/templates/main/daily_jobs.html index 8a42b15..38456d9 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -199,6 +199,9 @@
New ticket
+
+ +
@@ -528,6 +531,7 @@ function bindInlineCreateForms() { var btnTicket = document.getElementById('dj_ticket_save'); var btnRemark = document.getElementById('dj_remark_save'); + var tCode = document.getElementById('dj_ticket_code'); var tDesc = document.getElementById('dj_ticket_description'); var tStatus = document.getElementById('dj_ticket_status'); var rBody = document.getElementById('dj_remark_body'); @@ -541,6 +545,7 @@ function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; + if (tCode) tCode.disabled = disabled; if (tDesc) tDesc.disabled = disabled; if (rBody) rBody.disabled = disabled; } @@ -552,13 +557,25 @@ btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; var description = tDesc ? tDesc.value : ''; + if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code, description: description}) }) .then(function () { + if (tCode) tCode.value = ''; if (tDesc) tDesc.value = ''; if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); diff --git a/containers/backupchecks/src/templates/main/remark_detail.html b/containers/backupchecks/src/templates/main/remark_detail.html index 3509fbf..696d846 100644 --- a/containers/backupchecks/src/templates/main/remark_detail.html +++ b/containers/backupchecks/src/templates/main/remark_detail.html @@ -16,19 +16,19 @@ {% endif %} -
+
- +
{{ remark.body or '' }}
{% if active_role in ['admin','operator'] %}
- + {% if not remark.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index 56d0e82..21c1a0d 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -205,7 +205,10 @@
- + +
+
+
@@ -825,6 +828,7 @@ table.addEventListener('change', function (e) { function bindInlineCreateForms() { var btnTicket = document.getElementById('rcm_ticket_save'); var btnRemark = document.getElementById('rcm_remark_save'); + var tCode = document.getElementById('rcm_ticket_code'); var tDesc = document.getElementById('rcm_ticket_description'); var tStatus = document.getElementById('rcm_ticket_status'); var rBody = document.getElementById('rcm_remark_body'); @@ -838,6 +842,7 @@ table.addEventListener('change', function (e) { function setDisabled(disabled) { if (btnTicket) btnTicket.disabled = disabled; if (btnRemark) btnRemark.disabled = disabled; + if (tCode) tCode.disabled = disabled; if (tDesc) tDesc.disabled = disabled; if (rBody) rBody.disabled = disabled; } @@ -849,13 +854,25 @@ table.addEventListener('change', function (e) { btnTicket.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } clearStatus(); + var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : ''; var description = tDesc ? tDesc.value : ''; + if (!ticket_code) { + if (tStatus) tStatus.textContent = 'Ticket number is required.'; + else alert('Ticket number is required.'); + return; + } + if (!/^T\d{8}\.\d{4}$/.test(ticket_code)) { + if (tStatus) tStatus.textContent = 'Invalid ticket number format. Expected TYYYYMMDD.####.'; + else alert('Invalid ticket number format. Expected TYYYYMMDD.####.'); + return; + } if (tStatus) tStatus.textContent = 'Saving...'; apiJson('/api/tickets', { method: 'POST', - body: JSON.stringify({job_run_id: currentRunId, description: description}) + body: JSON.stringify({job_run_id: currentRunId, ticket_code: ticket_code, description: description}) }) .then(function () { + if (tCode) tCode.value = ''; if (tDesc) tDesc.value = ''; if (tStatus) tStatus.textContent = ''; loadAlerts(currentRunId); diff --git a/containers/backupchecks/src/templates/main/ticket_detail.html b/containers/backupchecks/src/templates/main/ticket_detail.html index 74b3ae5..12869dd 100644 --- a/containers/backupchecks/src/templates/main/ticket_detail.html +++ b/containers/backupchecks/src/templates/main/ticket_detail.html @@ -17,19 +17,19 @@ {% endif %} -
+
- +
{{ ticket.description or '' }}
{% if active_role in ['admin','operator'] %}
- + {% if not ticket.resolved_at %} {% endif %}
{% endif %} - +
diff --git a/docs/changelog.md b/docs/changelog.md index c526080..18ecc43 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -55,6 +55,16 @@ - Ensured replies are only allowed while the Feedback item remains in the Open state. - Stored user replies linked to the original Feedback item for audit and history purposes. +--- + +## v20260106-08-ticket-code-input-disable-edit + +- Changed ticket creation to use the user-provided ticket_code instead of generating a new code. +- Added server-side validation for ticket_code format (TYYYYMMDD.####) and uniqueness checks. +- Added ticket number input field to Daily Jobs and Run Checks “New ticket” forms and included client-side format validation. +- Disabled editing of tickets and remarks (UI forms made read-only and update endpoints now return an error instructing to resolve and recreate). +- Updated Ticket and Remark detail pages to remove save/edit actions while keeping resolve functionality. + ================================================================================================================================================ ## v0.1.16