Auto-commit local changes before build (2026-01-06 12:16:27) #42
@ -1 +1 @@
|
|||||||
v20260106-07-feedback-open-reply
|
v20260106-08-ticket-code-input-disable-edit
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from .routes_shared import * # noqa: F401,F403
|
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/<int:run_id>/alerts")
|
@main_bp.route("/api/job-runs/<int:run_id>/alerts")
|
||||||
@login_required
|
@login_required
|
||||||
@ -194,10 +195,21 @@ def api_tickets():
|
|||||||
job = Job.query.get(run.job_id) if run else None
|
job = Job.query.get(run.job_id) if run else None
|
||||||
|
|
||||||
now = datetime.utcnow()
|
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 = Ticket(
|
||||||
ticket_code=code,
|
ticket_code=ticket_code,
|
||||||
title=None,
|
title=None,
|
||||||
description=description,
|
description=description,
|
||||||
active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(),
|
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
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
def api_ticket_update(ticket_id: int):
|
def api_ticket_update(ticket_id: int):
|
||||||
if get_active_role() not in ("admin", "operator"):
|
# Editing tickets is not allowed. Resolve the old ticket and create a new one instead.
|
||||||
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
return jsonify({"status": "error", "message": "Ticket editing is disabled. Resolve the old ticket and create a new one."}), 405
|
||||||
|
|
||||||
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"})
|
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/api/tickets/<int:ticket_id>/resolve", methods=["POST"])
|
@main_bp.route("/api/tickets/<int:ticket_id>/resolve", methods=["POST"])
|
||||||
@ -420,21 +419,8 @@ def api_remarks():
|
|||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
def api_remark_update(remark_id: int):
|
def api_remark_update(remark_id: int):
|
||||||
if get_active_role() not in ("admin", "operator"):
|
# Editing remarks is not allowed. Resolve the old remark and create a new one instead.
|
||||||
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
return jsonify({"status": "error", "message": "Remark editing is disabled. Resolve the old remark and create a new one."}), 405
|
||||||
|
|
||||||
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"})
|
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/api/remarks/<int:remark_id>/resolve", methods=["POST"])
|
@main_bp.route("/api/remarks/<int:remark_id>/resolve", methods=["POST"])
|
||||||
|
|||||||
@ -1,22 +1,13 @@
|
|||||||
from .routes_shared import * # noqa: F401,F403
|
from .routes_shared import * # noqa: F401,F403
|
||||||
from .routes_shared import _format_datetime
|
from .routes_shared import _format_datetime
|
||||||
|
|
||||||
@main_bp.route("/remarks/<int:remark_id>", methods=["GET", "POST"])
|
@main_bp.route("/remarks/<int:remark_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
def remark_detail(remark_id: int):
|
def remark_detail(remark_id: int):
|
||||||
remark = Remark.query.get_or_404(remark_id)
|
remark = Remark.query.get_or_404(remark_id)
|
||||||
|
|
||||||
if request.method == "POST":
|
# Remark editing is disabled. Resolve the old remark and create a new one instead.
|
||||||
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")
|
|
||||||
return redirect(url_for("main.remark_detail", remark_id=remark.id))
|
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()
|
scopes = RemarkScope.query.filter(RemarkScope.remark_id == remark.id).order_by(RemarkScope.id.asc()).all()
|
||||||
|
|||||||
@ -270,22 +270,13 @@ def tickets_page():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/tickets/<int:ticket_id>", methods=["GET", "POST"])
|
@main_bp.route("/tickets/<int:ticket_id>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
def ticket_detail(ticket_id: int):
|
def ticket_detail(ticket_id: int):
|
||||||
ticket = Ticket.query.get_or_404(ticket_id)
|
ticket = Ticket.query.get_or_404(ticket_id)
|
||||||
|
|
||||||
if request.method == "POST":
|
# Ticket editing is disabled. Resolve the old ticket and create a new one instead.
|
||||||
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")
|
|
||||||
return redirect(url_for("main.ticket_detail", ticket_id=ticket.id))
|
return redirect(url_for("main.ticket_detail", ticket_id=ticket.id))
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
|
|||||||
@ -199,6 +199,9 @@
|
|||||||
<div class="fw-semibold">New ticket</div>
|
<div class="fw-semibold">New ticket</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="dj_ticket_save">Add</button>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="dj_ticket_save">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input class="form-control form-control-sm" id="dj_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||||
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<textarea class="form-control form-control-sm" id="dj_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
<textarea class="form-control form-control-sm" id="dj_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@ -528,6 +531,7 @@
|
|||||||
function bindInlineCreateForms() {
|
function bindInlineCreateForms() {
|
||||||
var btnTicket = document.getElementById('dj_ticket_save');
|
var btnTicket = document.getElementById('dj_ticket_save');
|
||||||
var btnRemark = document.getElementById('dj_remark_save');
|
var btnRemark = document.getElementById('dj_remark_save');
|
||||||
|
var tCode = document.getElementById('dj_ticket_code');
|
||||||
var tDesc = document.getElementById('dj_ticket_description');
|
var tDesc = document.getElementById('dj_ticket_description');
|
||||||
var tStatus = document.getElementById('dj_ticket_status');
|
var tStatus = document.getElementById('dj_ticket_status');
|
||||||
var rBody = document.getElementById('dj_remark_body');
|
var rBody = document.getElementById('dj_remark_body');
|
||||||
@ -541,6 +545,7 @@
|
|||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
if (btnTicket) btnTicket.disabled = disabled;
|
if (btnTicket) btnTicket.disabled = disabled;
|
||||||
if (btnRemark) btnRemark.disabled = disabled;
|
if (btnRemark) btnRemark.disabled = disabled;
|
||||||
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (tDesc) tDesc.disabled = disabled;
|
if (tDesc) tDesc.disabled = disabled;
|
||||||
if (rBody) rBody.disabled = disabled;
|
if (rBody) rBody.disabled = disabled;
|
||||||
}
|
}
|
||||||
@ -552,13 +557,25 @@
|
|||||||
btnTicket.addEventListener('click', function () {
|
btnTicket.addEventListener('click', function () {
|
||||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
clearStatus();
|
clearStatus();
|
||||||
|
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||||
var description = tDesc ? tDesc.value : '';
|
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...';
|
if (tStatus) tStatus.textContent = 'Saving...';
|
||||||
apiJson('/api/tickets', {
|
apiJson('/api/tickets', {
|
||||||
method: 'POST',
|
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 () {
|
.then(function () {
|
||||||
|
if (tCode) tCode.value = '';
|
||||||
if (tDesc) tDesc.value = '';
|
if (tDesc) tDesc.value = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
loadAlerts(currentRunId);
|
loadAlerts(currentRunId);
|
||||||
|
|||||||
@ -16,19 +16,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="row g-3"> <div class="col-12">
|
<div class="row g-3"> <div class="col-12">
|
||||||
<label class="form-label">Body</label>
|
<label class="form-label">Body</label>
|
||||||
<textarea class="form-control" name="body" rows="6">{{ remark.body or '' }}</textarea>
|
<div class="form-control-plaintext border rounded p-2" style="min-height: 7rem; white-space: pre-wrap;">{{ remark.body or '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if active_role in ['admin','operator'] %}
|
{% if active_role in ['admin','operator'] %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button class="btn btn-primary" type="submit">Save</button>
|
|
||||||
{% if not remark.resolved_at %}
|
{% if not remark.resolved_at %}
|
||||||
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark remark as resolved?')){fetch('{{ url_for('main.api_remark_resolve', remark_id=remark.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
|
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark remark as resolved?')){fetch('{{ url_for('main.api_remark_resolve', remark_id=remark.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -204,6 +204,9 @@
|
|||||||
<div class="fw-semibold">New ticket</div>
|
<div class="fw-semibold">New ticket</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="rcm_ticket_save">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||||
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<textarea class="form-control form-control-sm" id="rcm_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
<textarea class="form-control form-control-sm" id="rcm_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@ -825,6 +828,7 @@ table.addEventListener('change', function (e) {
|
|||||||
function bindInlineCreateForms() {
|
function bindInlineCreateForms() {
|
||||||
var btnTicket = document.getElementById('rcm_ticket_save');
|
var btnTicket = document.getElementById('rcm_ticket_save');
|
||||||
var btnRemark = document.getElementById('rcm_remark_save');
|
var btnRemark = document.getElementById('rcm_remark_save');
|
||||||
|
var tCode = document.getElementById('rcm_ticket_code');
|
||||||
var tDesc = document.getElementById('rcm_ticket_description');
|
var tDesc = document.getElementById('rcm_ticket_description');
|
||||||
var tStatus = document.getElementById('rcm_ticket_status');
|
var tStatus = document.getElementById('rcm_ticket_status');
|
||||||
var rBody = document.getElementById('rcm_remark_body');
|
var rBody = document.getElementById('rcm_remark_body');
|
||||||
@ -838,6 +842,7 @@ table.addEventListener('change', function (e) {
|
|||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
if (btnTicket) btnTicket.disabled = disabled;
|
if (btnTicket) btnTicket.disabled = disabled;
|
||||||
if (btnRemark) btnRemark.disabled = disabled;
|
if (btnRemark) btnRemark.disabled = disabled;
|
||||||
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (tDesc) tDesc.disabled = disabled;
|
if (tDesc) tDesc.disabled = disabled;
|
||||||
if (rBody) rBody.disabled = disabled;
|
if (rBody) rBody.disabled = disabled;
|
||||||
}
|
}
|
||||||
@ -849,13 +854,25 @@ table.addEventListener('change', function (e) {
|
|||||||
btnTicket.addEventListener('click', function () {
|
btnTicket.addEventListener('click', function () {
|
||||||
if (!currentRunId) { alert('Select a run first.'); return; }
|
if (!currentRunId) { alert('Select a run first.'); return; }
|
||||||
clearStatus();
|
clearStatus();
|
||||||
|
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||||
var description = tDesc ? tDesc.value : '';
|
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...';
|
if (tStatus) tStatus.textContent = 'Saving...';
|
||||||
apiJson('/api/tickets', {
|
apiJson('/api/tickets', {
|
||||||
method: 'POST',
|
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 () {
|
.then(function () {
|
||||||
|
if (tCode) tCode.value = '';
|
||||||
if (tDesc) tDesc.value = '';
|
if (tDesc) tDesc.value = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
loadAlerts(currentRunId);
|
loadAlerts(currentRunId);
|
||||||
|
|||||||
@ -17,19 +17,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="row g-3"> <div class="col-12">
|
<div class="row g-3"> <div class="col-12">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<textarea class="form-control" name="description" rows="5">{{ ticket.description or '' }}</textarea>
|
<div class="form-control-plaintext border rounded p-2" style="min-height: 6rem; white-space: pre-wrap;">{{ ticket.description or '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if active_role in ['admin','operator'] %}
|
{% if active_role in ['admin','operator'] %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button class="btn btn-primary" type="submit">Save</button>
|
|
||||||
{% if not ticket.resolved_at %}
|
{% if not ticket.resolved_at %}
|
||||||
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark ticket as resolved?')){fetch('{{ url_for('main.api_ticket_resolve', ticket_id=ticket.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
|
<button class="btn btn-outline-success" type="button" onclick="if(confirm('Mark ticket as resolved?')){fetch('{{ url_for('main.api_ticket_resolve', ticket_id=ticket.id) }}',{method:'POST'}).then(()=>location.reload());}">Resolve</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,16 @@
|
|||||||
- Ensured replies are only allowed while the Feedback item remains in the Open state.
|
- 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.
|
- 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
|
## v0.1.16
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user