Auto-commit local changes before build (2026-01-06 15:12:25)
This commit is contained in:
parent
39b4ec6064
commit
f736a62ed5
@ -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
|
||||||
@ -178,7 +179,7 @@ def api_tickets():
|
|||||||
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
description = (payload.get("description") or "").strip() or None
|
description = None # Description removed from New ticket UI; use remarks for additional context
|
||||||
try:
|
try:
|
||||||
run_id = int(payload.get("job_run_id") or 0)
|
run_id = int(payload.get("job_run_id") or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -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"])
|
||||||
@ -489,4 +475,4 @@ def api_remark_link_run(remark_id: int):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500
|
return jsonify({"status": "error", "message": str(exc) or "Failed to link run."}), 500
|
||||||
|
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
@ -100,6 +100,9 @@ def customers_delete(customer_id: int):
|
|||||||
customer = Customer.query.get_or_404(customer_id)
|
customer = Customer.query.get_or_404(customer_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Prevent FK violations on older schemas and keep jobs for historical reporting.
|
||||||
|
# Jobs are not deleted when removing a customer; they are simply unlinked.
|
||||||
|
Job.query.filter_by(customer_id=customer.id).update({"customer_id": None})
|
||||||
db.session.delete(customer)
|
db.session.delete(customer)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Customer deleted.", "success")
|
flash("Customer deleted.", "success")
|
||||||
|
|||||||
@ -76,6 +76,7 @@ def daily_jobs():
|
|||||||
|
|
||||||
jobs = (
|
jobs = (
|
||||||
Job.query.join(Customer, isouter=True)
|
Job.query.join(Customer, isouter=True)
|
||||||
|
.filter(Job.archived.is_(False))
|
||||||
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
|
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -172,6 +172,19 @@ def feedback_detail(item_id: int):
|
|||||||
resolved_by = User.query.get(item.resolved_by_user_id)
|
resolved_by = User.query.get(item.resolved_by_user_id)
|
||||||
resolved_by_name = resolved_by.username if resolved_by else ""
|
resolved_by_name = resolved_by.username if resolved_by else ""
|
||||||
|
|
||||||
|
|
||||||
|
replies = (
|
||||||
|
FeedbackReply.query.filter(FeedbackReply.feedback_item_id == item.id)
|
||||||
|
.order_by(FeedbackReply.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_user_ids = sorted({int(r.user_id) for r in replies})
|
||||||
|
reply_users = (
|
||||||
|
User.query.filter(User.id.in_(reply_user_ids)).all() if reply_user_ids else []
|
||||||
|
)
|
||||||
|
reply_user_map = {int(u.id): (u.username or "") for u in reply_users}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"main/feedback_detail.html",
|
"main/feedback_detail.html",
|
||||||
item=item,
|
item=item,
|
||||||
@ -179,8 +192,42 @@ def feedback_detail(item_id: int):
|
|||||||
resolved_by_name=resolved_by_name,
|
resolved_by_name=resolved_by_name,
|
||||||
vote_count=int(vote_count),
|
vote_count=int(vote_count),
|
||||||
user_voted=bool(user_voted),
|
user_voted=bool(user_voted),
|
||||||
|
replies=replies,
|
||||||
|
reply_user_map=reply_user_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@main_bp.route("/feedback/<int:item_id>/reply", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator", "viewer")
|
||||||
|
def feedback_reply(item_id: int):
|
||||||
|
item = FeedbackItem.query.get_or_404(item_id)
|
||||||
|
if item.deleted_at is not None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if (item.status or "").strip().lower() != "open":
|
||||||
|
flash("Only open feedback items can be replied to.", "warning")
|
||||||
|
return redirect(url_for("main.feedback_detail", item_id=item.id))
|
||||||
|
|
||||||
|
message = (request.form.get("message") or "").strip()
|
||||||
|
if not message:
|
||||||
|
flash("Reply message is required.", "danger")
|
||||||
|
return redirect(url_for("main.feedback_detail", item_id=item.id))
|
||||||
|
|
||||||
|
reply = FeedbackReply(
|
||||||
|
feedback_item_id=int(item.id),
|
||||||
|
user_id=int(current_user.id),
|
||||||
|
message=message,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.session.add(reply)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Reply added.", "success")
|
||||||
|
return redirect(url_for("main.feedback_detail", item_id=item.id))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/feedback/<int:item_id>/vote", methods=["POST"])
|
@main_bp.route("/feedback/<int:item_id>/vote", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@ -69,6 +69,8 @@ def inbox():
|
|||||||
has_prev=has_prev,
|
has_prev=has_prev,
|
||||||
has_next=has_next,
|
has_next=has_next,
|
||||||
customers=customer_rows,
|
customers=customer_rows,
|
||||||
|
can_bulk_delete=(get_active_role() in ("admin", "operator")),
|
||||||
|
is_admin=(get_active_role() == "admin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -296,6 +298,62 @@ def inbox_message_delete(message_id: int):
|
|||||||
return redirect(url_for("main.inbox"))
|
return redirect(url_for("main.inbox"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/api/inbox/delete")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def api_inbox_bulk_delete():
|
||||||
|
"""Bulk delete inbox messages (soft delete -> move to Deleted)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
message_ids = data.get("message_ids") or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_ids = [int(x) for x in message_ids]
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"status": "error", "message": "Invalid message_ids."}), 400
|
||||||
|
|
||||||
|
if not message_ids:
|
||||||
|
return jsonify({"status": "ok", "updated": 0, "skipped": 0, "missing": 0})
|
||||||
|
|
||||||
|
msgs = MailMessage.query.filter(MailMessage.id.in_(message_ids)).all()
|
||||||
|
msg_map = {int(m.id): m for m in msgs}
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
for mid in message_ids:
|
||||||
|
msg = msg_map.get(int(mid))
|
||||||
|
if not msg:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if getattr(msg, "location", "inbox") != "inbox":
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(msg, "location"):
|
||||||
|
msg.location = "deleted"
|
||||||
|
if hasattr(msg, "deleted_at"):
|
||||||
|
msg.deleted_at = now
|
||||||
|
if hasattr(msg, "deleted_by_user_id"):
|
||||||
|
msg.deleted_by_user_id = current_user.id
|
||||||
|
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.session.rollback()
|
||||||
|
_log_admin_event("inbox_bulk_delete_error", f"Failed to bulk delete inbox messages {message_ids}: {exc}")
|
||||||
|
return jsonify({"status": "error", "message": "Database error while deleting messages."}), 500
|
||||||
|
|
||||||
|
_log_admin_event("inbox_bulk_delete", f"Deleted inbox messages: {message_ids}")
|
||||||
|
return jsonify({"status": "ok", "updated": updated, "skipped": skipped, "missing": missing})
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/inbox/deleted")
|
@main_bp.route("/inbox/deleted")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin")
|
@roles_required("admin")
|
||||||
|
|||||||
@ -16,6 +16,7 @@ def jobs():
|
|||||||
# Join with customers for display
|
# Join with customers for display
|
||||||
jobs = (
|
jobs = (
|
||||||
Job.query
|
Job.query
|
||||||
|
.filter(Job.archived.is_(False))
|
||||||
.outerjoin(Customer, Customer.id == Job.customer_id)
|
.outerjoin(Customer, Customer.id == Job.customer_id)
|
||||||
.add_columns(
|
.add_columns(
|
||||||
Job.id,
|
Job.id,
|
||||||
@ -55,6 +56,89 @@ def jobs():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/jobs/<int:job_id>/archive", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin", "operator")
|
||||||
|
def archive_job(job_id: int):
|
||||||
|
job = Job.query.get_or_404(job_id)
|
||||||
|
|
||||||
|
if job.archived:
|
||||||
|
flash("Job is already archived.", "info")
|
||||||
|
return redirect(url_for("main.jobs"))
|
||||||
|
|
||||||
|
job.archived = True
|
||||||
|
job.archived_at = datetime.utcnow()
|
||||||
|
job.archived_by_user_id = current_user.id
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_admin_event("job_archived", f"Archived job {job.id}", details=f"job_name={job.job_name}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash("Job archived.", "success")
|
||||||
|
return redirect(url_for("main.jobs"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/archived-jobs")
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def archived_jobs():
|
||||||
|
rows = (
|
||||||
|
Job.query
|
||||||
|
.filter(Job.archived.is_(True))
|
||||||
|
.outerjoin(Customer, Customer.id == Job.customer_id)
|
||||||
|
.add_columns(
|
||||||
|
Job.id,
|
||||||
|
Job.backup_software,
|
||||||
|
Job.backup_type,
|
||||||
|
Job.job_name,
|
||||||
|
Job.archived_at,
|
||||||
|
Customer.name.label("customer_name"),
|
||||||
|
)
|
||||||
|
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"customer_name": getattr(row, "customer_name", "") or "",
|
||||||
|
"backup_software": row.backup_software or "",
|
||||||
|
"backup_type": row.backup_type or "",
|
||||||
|
"job_name": row.job_name or "",
|
||||||
|
"archived_at": _format_datetime(row.archived_at),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("main/archived_jobs.html", jobs=out)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/jobs/<int:job_id>/unarchive", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@roles_required("admin")
|
||||||
|
def unarchive_job(job_id: int):
|
||||||
|
job = Job.query.get_or_404(job_id)
|
||||||
|
if not job.archived:
|
||||||
|
flash("Job is not archived.", "info")
|
||||||
|
return redirect(url_for("main.archived_jobs"))
|
||||||
|
|
||||||
|
job.archived = False
|
||||||
|
job.archived_at = None
|
||||||
|
job.archived_by_user_id = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_admin_event("job_unarchived", f"Unarchived job {job.id}", details=f"job_name={job.job_name}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
flash("Job restored.", "success")
|
||||||
|
return redirect(url_for("main.archived_jobs"))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/jobs/<int:job_id>")
|
@main_bp.route("/jobs/<int:job_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
|
|||||||
@ -1,23 +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))
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@ -331,14 +331,17 @@ def build_report_columns_meta():
|
|||||||
def build_report_job_filters_meta():
|
def build_report_job_filters_meta():
|
||||||
"""Build job filter metadata for reporting UIs.
|
"""Build job filter metadata for reporting UIs.
|
||||||
|
|
||||||
Provides available backup_softwares and backup_types derived from active jobs.
|
Provides available backup_softwares and backup_types derived from jobs that have runs.
|
||||||
"""
|
"""
|
||||||
# Distinct values across active jobs (exclude known informational jobs).
|
# Distinct values across jobs with runs (exclude known informational jobs).
|
||||||
info_backup_types = {"license key"}
|
info_backup_types = {"license key"}
|
||||||
|
|
||||||
|
# Distinct values across jobs that actually have at least one run.
|
||||||
rows = (
|
rows = (
|
||||||
db.session.query(Job.backup_software, Job.backup_type)
|
db.session.query(Job.backup_software, Job.backup_type)
|
||||||
.filter(Job.active.is_(True))
|
.select_from(Job)
|
||||||
|
.join(JobRun, JobRun.job_id == Job.id)
|
||||||
|
.distinct()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -195,7 +195,7 @@ def run_checks_page():
|
|||||||
)
|
)
|
||||||
last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows}
|
last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows}
|
||||||
|
|
||||||
jobs = Job.query.all()
|
jobs = Job.query.filter(Job.archived.is_(False)).all()
|
||||||
today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date()
|
today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date()
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
@ -222,6 +222,7 @@ def run_checks_page():
|
|||||||
)
|
)
|
||||||
.select_from(Job)
|
.select_from(Job)
|
||||||
.outerjoin(Customer, Customer.id == Job.customer_id)
|
.outerjoin(Customer, Customer.id == Job.customer_id)
|
||||||
|
.filter(Job.archived.is_(False))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Runs to show in the overview: unreviewed (or all if admin toggle enabled)
|
# Runs to show in the overview: unreviewed (or all if admin toggle enabled)
|
||||||
|
|||||||
@ -50,6 +50,7 @@ from ..models import (
|
|||||||
RemarkJobRun,
|
RemarkJobRun,
|
||||||
FeedbackItem,
|
FeedbackItem,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
|
FeedbackReply,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
NewsRead,
|
NewsRead,
|
||||||
ReportDefinition,
|
ReportDefinition,
|
||||||
|
|||||||
@ -270,23 +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))
|
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all()
|
scopes = TicketScope.query.filter(TicketScope.ticket_id == ticket.id).order_by(TicketScope.id.asc()).all()
|
||||||
|
|||||||
@ -772,17 +772,59 @@ def run_migrations() -> None:
|
|||||||
migrate_mail_objects_table()
|
migrate_mail_objects_table()
|
||||||
migrate_object_persistence_tables()
|
migrate_object_persistence_tables()
|
||||||
migrate_feedback_tables()
|
migrate_feedback_tables()
|
||||||
|
migrate_feedback_replies_table()
|
||||||
migrate_tickets_active_from_date()
|
migrate_tickets_active_from_date()
|
||||||
migrate_remarks_active_from_date()
|
migrate_remarks_active_from_date()
|
||||||
migrate_overrides_match_columns()
|
migrate_overrides_match_columns()
|
||||||
migrate_job_runs_review_tracking()
|
migrate_job_runs_review_tracking()
|
||||||
migrate_job_runs_override_metadata()
|
migrate_job_runs_override_metadata()
|
||||||
|
migrate_jobs_archiving()
|
||||||
migrate_news_tables()
|
migrate_news_tables()
|
||||||
migrate_reporting_tables()
|
migrate_reporting_tables()
|
||||||
migrate_reporting_report_config()
|
migrate_reporting_report_config()
|
||||||
print("[migrations] All migrations completed.")
|
print("[migrations] All migrations completed.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_jobs_archiving() -> None:
|
||||||
|
"""Add archiving columns to jobs if missing.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
- jobs.archived (BOOLEAN NOT NULL DEFAULT FALSE)
|
||||||
|
- jobs.archived_at (TIMESTAMP NULL)
|
||||||
|
- jobs.archived_by_user_id (INTEGER NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
table = "jobs"
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = db.get_engine()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] Could not get engine for jobs archiving migration: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
try:
|
||||||
|
existing_columns = {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[migrations] {table} table not found for jobs archiving migration, skipping: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if "archived" not in existing_columns:
|
||||||
|
print('[migrations] Adding jobs.archived column...')
|
||||||
|
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE'))
|
||||||
|
|
||||||
|
if "archived_at" not in existing_columns:
|
||||||
|
print('[migrations] Adding jobs.archived_at column...')
|
||||||
|
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_at TIMESTAMP'))
|
||||||
|
|
||||||
|
if "archived_by_user_id" not in existing_columns:
|
||||||
|
print('[migrations] Adding jobs.archived_by_user_id column...')
|
||||||
|
conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_by_user_id INTEGER'))
|
||||||
|
|
||||||
|
print("[migrations] migrate_jobs_archiving completed.")
|
||||||
|
|
||||||
|
|
||||||
def migrate_reporting_report_config() -> None:
|
def migrate_reporting_report_config() -> None:
|
||||||
"""Add report_definitions.report_config column if missing.
|
"""Add report_definitions.report_config column if missing.
|
||||||
|
|
||||||
@ -937,6 +979,40 @@ def migrate_job_runs_review_tracking() -> None:
|
|||||||
print("[migrations] migrate_job_runs_review_tracking completed.")
|
print("[migrations] migrate_job_runs_review_tracking completed.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_feedback_replies_table() -> None:
|
||||||
|
"""Ensure feedback reply table exists.
|
||||||
|
|
||||||
|
Table:
|
||||||
|
- feedback_replies (messages on open feedback items)
|
||||||
|
"""
|
||||||
|
engine = db.get_engine()
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback_replies (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
feedback_item_id INTEGER NOT NULL REFERENCES feedback_items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_feedback_replies_item_created_at
|
||||||
|
ON feedback_replies (feedback_item_id, created_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[migrations] Feedback replies table ensured.")
|
||||||
|
|
||||||
|
|
||||||
def migrate_tickets_active_from_date() -> None:
|
def migrate_tickets_active_from_date() -> None:
|
||||||
"""Ensure tickets.active_from_date exists and is populated.
|
"""Ensure tickets.active_from_date exists and is populated.
|
||||||
|
|
||||||
@ -1096,6 +1172,39 @@ def migrate_object_persistence_tables() -> None:
|
|||||||
'''
|
'''
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ensure existing installations also have ON DELETE CASCADE on customer_objects.customer_id.
|
||||||
|
# Older schemas created the FK without cascade, which blocks deleting customers.
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
'''
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
WHERE tc.table_name = 'customer_objects'
|
||||||
|
AND tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND tc.constraint_name = 'customer_objects_customer_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE customer_objects
|
||||||
|
DROP CONSTRAINT customer_objects_customer_id_fkey;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Recreate with cascade (idempotent via the drop above)
|
||||||
|
ALTER TABLE customer_objects
|
||||||
|
ADD CONSTRAINT customer_objects_customer_id_fkey
|
||||||
|
FOREIGN KEY (customer_id)
|
||||||
|
REFERENCES customers(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN
|
||||||
|
-- Constraint already exists with the correct name.
|
||||||
|
NULL;
|
||||||
|
END $$;
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_customer_objects_customer_name ON customer_objects (customer_id, object_name)'
|
'CREATE INDEX IF NOT EXISTS idx_customer_objects_customer_name ON customer_objects (customer_id, object_name)'
|
||||||
|
|||||||
@ -196,6 +196,12 @@ class Job(db.Model):
|
|||||||
auto_approve = db.Column(db.Boolean, nullable=False, default=True)
|
auto_approve = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
# Archived jobs are excluded from Daily Jobs and Run Checks.
|
||||||
|
# JobRuns remain in the database and are still included in reporting.
|
||||||
|
archived = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
archived_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
archived_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
@ -488,6 +494,20 @@ class FeedbackVote(db.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackReply(db.Model):
|
||||||
|
__tablename__ = "feedback_replies"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
feedback_item_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||||
|
message = db.Column(db.Text, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class NewsItem(db.Model):
|
class NewsItem(db.Model):
|
||||||
__tablename__ = "news_items"
|
__tablename__ = "news_items"
|
||||||
|
|
||||||
|
|||||||
@ -79,7 +79,9 @@ def _extract_configuration_job_overall_message(html: str) -> Optional[str]:
|
|||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
# Example:
|
# Example:
|
||||||
# 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled
|
# 26-12-2025 10:00:23 Warning Skipping server certificate backup because encryption is disabled
|
||||||
if re.match(r"^\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line):
|
# 6-1-2026 10:00:16 Warning Skipping credentials backup because encryption is disabled
|
||||||
|
# Veeam can format dates as either zero-padded (06-01-2026) or non-padded (6-1-2026).
|
||||||
|
if re.match(r"^\d{1,2}-\d{1,2}-\d{4}\s+\d{2}:\d{2}:\d{2}\s+(Warning|Failed|Error)\b", line):
|
||||||
wanted_lines.append(line)
|
wanted_lines.append(line)
|
||||||
|
|
||||||
if not wanted_lines:
|
if not wanted_lines:
|
||||||
@ -711,6 +713,20 @@ def _strip_m365_combined_suffix(job_name: Optional[str]) -> Optional[str]:
|
|||||||
return cleaned or None
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_full_suffix(job_name: Optional[str]) -> Optional[str]:
|
||||||
|
"""Remove a trailing "(Full)" suffix from a Veeam job name.
|
||||||
|
|
||||||
|
Some Veeam installations create separate emails where the job name is
|
||||||
|
suffixed with "(Full)" (e.g. "Backup VM DC01 (Full)"). Those should be
|
||||||
|
treated as the same logical job as the non-suffixed name.
|
||||||
|
"""
|
||||||
|
if not job_name:
|
||||||
|
return job_name
|
||||||
|
|
||||||
|
cleaned = re.sub(r"\s*\(\s*Full\s*\)\s*$", "", job_name, flags=re.IGNORECASE).strip()
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
||||||
"""Try to parse a Veeam backup report mail.
|
"""Try to parse a Veeam backup report mail.
|
||||||
|
|
||||||
@ -866,6 +882,10 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
|||||||
# Do not let retry counters create distinct job names.
|
# Do not let retry counters create distinct job names.
|
||||||
job_name = _strip_retry_suffix(job_name)
|
job_name = _strip_retry_suffix(job_name)
|
||||||
|
|
||||||
|
# Veeam can append a "(Full)" suffix to the job name in some reports.
|
||||||
|
# Strip it so full/non-full mails map to the same logical job.
|
||||||
|
job_name = _strip_full_suffix(job_name)
|
||||||
|
|
||||||
# Veeam Backup for Microsoft 365 reports can add a "(Combined)" suffix.
|
# Veeam Backup for Microsoft 365 reports can add a "(Combined)" suffix.
|
||||||
# Strip it so combined/non-combined mails map to the same job.
|
# Strip it so combined/non-combined mails map to the same job.
|
||||||
if (backup_type or "") == "Veeam Backup for Microsoft 365":
|
if (backup_type or "") == "Veeam Backup for Microsoft 365":
|
||||||
@ -885,7 +905,7 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
|||||||
result: Dict = {
|
result: Dict = {
|
||||||
"backup_software": "Veeam",
|
"backup_software": "Veeam",
|
||||||
"backup_type": backup_type,
|
"backup_type": backup_type,
|
||||||
"job_name": _strip_retry_suffix(job_name),
|
"job_name": job_name,
|
||||||
"overall_status": status_word,
|
"overall_status": status_word,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -912,8 +932,17 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
|
|||||||
|
|
||||||
# Keep detailed overall message for non-success states, and always keep
|
# Keep detailed overall message for non-success states, and always keep
|
||||||
# the "Processing <object>" marker when present (used for overrides/rules).
|
# the "Processing <object>" marker when present (used for overrides/rules).
|
||||||
|
# Veeam Backup for Microsoft 365 can include a meaningful overall warning/info
|
||||||
|
# even when the run is reported as Success (e.g. missing application
|
||||||
|
# permissions/roles). Store it so it becomes visible in details and can be
|
||||||
|
# used for overrides.
|
||||||
|
is_m365 = (backup_type or "") == "Veeam Backup for Microsoft 365"
|
||||||
if overall_message:
|
if overall_message:
|
||||||
if status_word != "Success" or overall_message.lower().startswith("processing "):
|
if (
|
||||||
|
status_word != "Success"
|
||||||
|
or overall_message.lower().startswith("processing ")
|
||||||
|
or is_m365
|
||||||
|
):
|
||||||
result["overall_message"] = overall_message
|
result["overall_message"] = overall_message
|
||||||
|
|
||||||
return True, result, objects
|
return True, result, objects
|
||||||
|
|||||||
@ -82,6 +82,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
|
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if active_role == 'admin' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.archived_jobs') }}">Archived Jobs</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
|
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -200,9 +200,9 @@
|
|||||||
<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">
|
<div class="mt-2">
|
||||||
<textarea class="form-control form-control-sm" id="dj_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
<input class="form-control form-control-sm" id="dj_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 small text-muted" id="dj_ticket_status"></div>
|
<div class="mt-2 small text-muted" id="dj_ticket_status"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
@ -356,27 +356,13 @@
|
|||||||
'<div class="text-truncate">' +
|
'<div class="text-truncate">' +
|
||||||
'<span class="me-1" title="Ticket">🎫</span>' +
|
'<span class="me-1" title="Ticket">🎫</span>' +
|
||||||
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
|
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
|
||||||
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(t.description ? ('<div class="small text-muted mt-1">' + escapeHtml(t.description) + '</div>') : '') +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="d-flex gap-1 flex-shrink-0">' +
|
'<div class="d-flex gap-1 flex-shrink-0">' +
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Edit</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mt-2" data-edit="ticket" style="display:none;">' +
|
|
||||||
'<div class="row g-2">' +
|
|
||||||
'<div class="col-12">' +
|
|
||||||
'<textarea class="form-control form-control-sm" data-field="description" rows="2" placeholder="Description (optional)">' + escapeHtml(t.description || '') + '</textarea>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="col-12 d-flex gap-2">' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-action="save-ticket" data-id="' + t.id + '">Save</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + t.id + '">Cancel</button>' +
|
|
||||||
'<div class="small text-muted align-self-center" data-field="status"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
@ -397,22 +383,9 @@
|
|||||||
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
|
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="d-flex gap-1 flex-shrink-0">' +
|
'<div class="d-flex gap-1 flex-shrink-0">' +
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Edit</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mt-2" data-edit="remark" style="display:none;">' +
|
|
||||||
'<div class="row g-2">' +
|
|
||||||
'<div class="col-12">' +
|
|
||||||
'<textarea class="form-control form-control-sm" data-field="body" rows="2" placeholder="Body (required)">' + escapeHtml(r.body || '') + '</textarea>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="col-12 d-flex gap-2">' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-action="save-remark" data-id="' + r.id + '">Save</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + r.id + '">Cancel</button>' +
|
|
||||||
'<div class="small text-muted align-self-center" data-field="status"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
@ -427,8 +400,6 @@
|
|||||||
var id = btn.getAttribute('data-id');
|
var id = btn.getAttribute('data-id');
|
||||||
if (!action || !id) return;
|
if (!action || !id) return;
|
||||||
|
|
||||||
var wrapper = btn.closest('[data-alert-type]');
|
|
||||||
|
|
||||||
if (action === 'resolve-ticket') {
|
if (action === 'resolve-ticket') {
|
||||||
if (!confirm('Mark ticket as resolved?')) return;
|
if (!confirm('Mark ticket as resolved?')) return;
|
||||||
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
||||||
@ -439,59 +410,6 @@
|
|||||||
apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
.then(function () { loadAlerts(currentRunId); })
|
||||||
.catch(function (e) { alert(e.message || 'Failed.'); });
|
.catch(function (e) { alert(e.message || 'Failed.'); });
|
||||||
} else if (action === 'toggle-edit-ticket') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var edit = wrapper.querySelector('[data-edit="ticket"]');
|
|
||||||
if (!edit) return;
|
|
||||||
edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none';
|
|
||||||
} else if (action === 'toggle-edit-remark') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var edit2 = wrapper.querySelector('[data-edit="remark"]');
|
|
||||||
if (!edit2) return;
|
|
||||||
edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none';
|
|
||||||
} else if (action === 'cancel-edit') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editAny = wrapper.querySelector('[data-edit]');
|
|
||||||
if (editAny) editAny.style.display = 'none';
|
|
||||||
} else if (action === 'save-ticket') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editT = wrapper.querySelector('[data-edit="ticket"]');
|
|
||||||
if (!editT) return;
|
|
||||||
var descEl = editT.querySelector('[data-field="description"]');
|
|
||||||
var statusEl = editT.querySelector('[data-field="status"]');
|
|
||||||
var descVal = descEl ? descEl.value : '';
|
|
||||||
if (statusEl) statusEl.textContent = 'Saving...';
|
|
||||||
apiJson('/api/tickets/' + encodeURIComponent(id), {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({description: descVal})
|
|
||||||
})
|
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
|
||||||
.catch(function (e) {
|
|
||||||
if (statusEl) statusEl.textContent = e.message || 'Failed.';
|
|
||||||
else alert(e.message || 'Failed.');
|
|
||||||
});
|
|
||||||
} else if (action === 'save-remark') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editR = wrapper.querySelector('[data-edit="remark"]');
|
|
||||||
if (!editR) return;
|
|
||||||
var bodyEl2 = editR.querySelector('[data-field="body"]');
|
|
||||||
var statusEl2 = editR.querySelector('[data-field="status"]');
|
|
||||||
var bodyVal2 = bodyEl2 ? bodyEl2.value : '';
|
|
||||||
if (!bodyVal2 || !bodyVal2.trim()) {
|
|
||||||
if (statusEl2) statusEl2.textContent = 'Body is required.';
|
|
||||||
else alert('Body is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (statusEl2) statusEl2.textContent = 'Saving...';
|
|
||||||
apiJson('/api/remarks/' + encodeURIComponent(id), {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({body: bodyVal2})
|
|
||||||
})
|
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
|
||||||
.catch(function (e) {
|
|
||||||
if (statusEl2) statusEl2.textContent = e.message || 'Failed.';
|
|
||||||
else alert(e.message || 'Failed.');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -528,8 +446,8 @@
|
|||||||
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 tDesc = document.getElementById('dj_ticket_description');
|
var tCode = document.getElementById('dj_ticket_code');
|
||||||
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');
|
||||||
var rStatus = document.getElementById('dj_remark_status');
|
var rStatus = document.getElementById('dj_remark_status');
|
||||||
|
|
||||||
@ -541,8 +459,8 @@
|
|||||||
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 (tDesc) tDesc.disabled = disabled;
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (rBody) rBody.disabled = disabled;
|
if (rBody) rBody.disabled = disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__djSetCreateDisabled = setDisabled;
|
window.__djSetCreateDisabled = setDisabled;
|
||||||
@ -552,15 +470,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 description = tDesc ? tDesc.value : '';
|
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||||
|
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})
|
||||||
})
|
})
|
||||||
.then(function () {
|
.then(function () {
|
||||||
if (tDesc) tDesc.value = '';
|
if (tCode) tCode.value = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
loadAlerts(currentRunId);
|
loadAlerts(currentRunId);
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
|
|||||||
@ -49,6 +49,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Replies</h5>
|
||||||
|
{% if replies %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for r in replies %}
|
||||||
|
<div class="list-group-item px-0">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<strong>{{ reply_user_map.get(r.user_id, '') or ('User #' ~ r.user_id) }}</strong>
|
||||||
|
<span class="text-muted" style="font-size: 0.85rem;">
|
||||||
|
{{ r.created_at.strftime('%d-%m-%Y %H:%M:%S') if r.created_at else '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="white-space: pre-wrap;">{{ r.message }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">No replies yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Add reply</h5>
|
||||||
|
{% if item.status == 'open' %}
|
||||||
|
<form method="post" action="{{ url_for('main.feedback_reply', item_id=item.id) }}">
|
||||||
|
<div class="mb-2">
|
||||||
|
<textarea class="form-control" name="message" rows="4" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Post reply</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Replies can only be added while the item is open.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="col-12 col-lg-4">
|
<div class="col-12 col-lg-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@ -56,10 +56,26 @@
|
|||||||
|
|
||||||
{{ pager("top", page, total_pages, has_prev, has_next) }}
|
{{ pager("top", page, total_pages, has_prev, has_next) }}
|
||||||
|
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" id="btn_inbox_delete_selected" disabled>Delete selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mb-2" id="inbox_status"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm table-hover align-middle">
|
<table class="table table-sm table-hover align-middle" id="inboxTable">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<th scope="col" style="width: 34px;">
|
||||||
|
<input class="form-check-input" type="checkbox" id="inbox_select_all" />
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
<th scope="col">From</th>
|
<th scope="col">From</th>
|
||||||
<th scope="col">Subject</th>
|
<th scope="col">Subject</th>
|
||||||
<th scope="col">Date / time</th>
|
<th scope="col">Date / time</th>
|
||||||
@ -75,6 +91,11 @@
|
|||||||
{% if rows %}
|
{% if rows %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
<tr class="inbox-row" data-message-id="{{ row.id }}" style="cursor: pointer;">
|
||||||
|
{% if can_bulk_delete %}
|
||||||
|
<td onclick="event.stopPropagation();">
|
||||||
|
<input class="form-check-input inbox_row_cb" type="checkbox" value="{{ row.id }}" />
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td>{{ row.from_address }}</td>
|
<td>{{ row.from_address }}</td>
|
||||||
<td>{{ row.subject }}</td>
|
<td>{{ row.subject }}</td>
|
||||||
<td>{{ row.received_at }}</td>
|
<td>{{ row.received_at }}</td>
|
||||||
@ -190,6 +211,125 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var customers = {{ customers|tojson|safe }};
|
var customers = {{ customers|tojson|safe }};
|
||||||
|
|
||||||
|
var table = document.getElementById('inboxTable');
|
||||||
|
var selectAll = document.getElementById('inbox_select_all');
|
||||||
|
var btnDeleteSelected = document.getElementById('btn_inbox_delete_selected');
|
||||||
|
var statusEl = document.getElementById('inbox_status');
|
||||||
|
|
||||||
|
function getSelectedMessageIds() {
|
||||||
|
if (!table) return [];
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
var ids = [];
|
||||||
|
cbs.forEach(function (cb) {
|
||||||
|
if (cb.checked) ids.push(parseInt(cb.value, 10));
|
||||||
|
});
|
||||||
|
return ids.filter(function (x) { return Number.isFinite(x); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshRowHighlights() {
|
||||||
|
if (!table) return;
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
cbs.forEach(function (cb) {
|
||||||
|
var tr = cb.closest ? cb.closest('tr') : null;
|
||||||
|
if (!tr) return;
|
||||||
|
if (cb.checked) tr.classList.add('table-active');
|
||||||
|
else tr.classList.remove('table-active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectAll() {
|
||||||
|
if (!selectAll || !table) return;
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
var total = cbs.length;
|
||||||
|
var checked = 0;
|
||||||
|
cbs.forEach(function (cb) { if (cb.checked) checked++; });
|
||||||
|
selectAll.indeterminate = checked > 0 && checked < total;
|
||||||
|
selectAll.checked = total > 0 && checked === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteUi() {
|
||||||
|
var ids = getSelectedMessageIds();
|
||||||
|
refreshRowHighlights();
|
||||||
|
if (btnDeleteSelected) btnDeleteSelected.disabled = ids.length === 0;
|
||||||
|
if (statusEl) statusEl.textContent = ids.length ? (ids.length + ' selected') : '';
|
||||||
|
refreshSelectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(url, payload) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAll && table) {
|
||||||
|
function setAllSelection(checked) {
|
||||||
|
var cbs = table.querySelectorAll('tbody .inbox_row_cb');
|
||||||
|
cbs.forEach(function (cb) { cb.checked = !!checked; });
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
selectAll.checked = !!checked;
|
||||||
|
setTimeout(function () {
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
selectAll.checked = !!checked;
|
||||||
|
}, 0);
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
setAllSelection(selectAll.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
table.addEventListener('change', function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (t && t.classList && t.classList.contains('inbox_row_cb')) {
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnDeleteSelected) {
|
||||||
|
btnDeleteSelected.addEventListener('click', function () {
|
||||||
|
var ids = getSelectedMessageIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
|
||||||
|
var msg = 'Delete ' + ids.length + ' selected message' + (ids.length === 1 ? '' : 's') + ' from the Inbox?';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
if (statusEl) statusEl.textContent = 'Deleting...';
|
||||||
|
|
||||||
|
postJson('{{ url_for('main.api_inbox_bulk_delete') }}', { message_ids: ids })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok || !res.data || res.data.status !== 'ok') {
|
||||||
|
var err = (res.data && (res.data.message || res.data.error)) ? (res.data.message || res.data.error) : 'Request failed.';
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
alert(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var err = 'Request failed.';
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
alert(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI state
|
||||||
|
updateBulkDeleteUi();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function wrapMailHtml(html) {
|
function wrapMailHtml(html) {
|
||||||
|
|||||||
@ -48,9 +48,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_manage_jobs %}
|
{% if can_manage_jobs %}
|
||||||
<form method="post" action="{{ url_for('main.job_delete', job_id=job.id) }}" class="mb-3" onsubmit="return confirm('Are you sure you want to delete this job? Related mails will be returned to the Inbox.');">
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
<button type="submit" class="btn btn-outline-danger">Delete job</button>
|
<form method="post" action="{{ url_for('main.archive_job', job_id=job.id) }}" class="mb-0" onsubmit="return confirm('Archive this job? No new runs are expected and it will be removed from Daily Jobs and Run Checks.');">
|
||||||
</form>
|
<button type="submit" class="btn btn-outline-secondary">Archive</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('main.job_delete', job_id=job.id) }}" class="mb-0" onsubmit="return confirm('Are you sure you want to delete this job? Related mails will be returned to the Inbox.');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">Delete job</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h3 class="mt-4 mb-3">Job history</h3>
|
<h3 class="mt-4 mb-3">Job history</h3>
|
||||||
@ -268,7 +274,7 @@
|
|||||||
"</body></html>"
|
"</body></html>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function renderObjects(objects) {
|
function renderObjects(objects) {
|
||||||
var container = document.getElementById("run_msg_objects_container");
|
var container = document.getElementById("run_msg_objects_container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@ -277,16 +283,36 @@ function renderObjects(objects) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort: objects with an error_message first (alphabetically by name), then the rest (also by name).
|
||||||
|
var sorted = (objects || []).slice().sort(function (a, b) {
|
||||||
|
a = a || {};
|
||||||
|
b = b || {};
|
||||||
|
var aHasErr = !!(a.error_message && a.error_message.toString().trim());
|
||||||
|
var bHasErr = !!(b.error_message && b.error_message.toString().trim());
|
||||||
|
if (aHasErr !== bHasErr) return aHasErr ? -1 : 1;
|
||||||
|
|
||||||
|
var an = (a.name || "").toString().toLowerCase();
|
||||||
|
var bn = (b.name || "").toString().toLowerCase();
|
||||||
|
if (an < bn) return -1;
|
||||||
|
if (an > bn) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
var html = "<div class=\"table-responsive\"><table class=\"table table-sm table-bordered mb-0\">";
|
var html = "<div class=\"table-responsive\"><table class=\"table table-sm table-bordered mb-0\">";
|
||||||
html += "<thead><tr><th>Object</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>";
|
html += "<thead><tr><th>Object</th><th>Type</th><th>Status</th><th>Error</th></tr></thead><tbody>";
|
||||||
for (var i = 0; i < objects.length; i++) {
|
for (var i = 0; i < sorted.length; i++) {
|
||||||
var o = objects[i] || {};
|
var o = sorted[i] || {};
|
||||||
html += "<tr>";
|
html += "<tr>";
|
||||||
html += "<td>" + (o.name || "") + "</td>";
|
html += "<td>" + escapeHtml(o.name || "") + "</td>";
|
||||||
html += "<td>" + (o.type || "") + "</td>";
|
html += "<td>" + escapeHtml(o.type || "") + "</td>";
|
||||||
|
|
||||||
var d = statusDotClass(o.status);
|
var d = statusDotClass(o.status);
|
||||||
html += "<td class=\"status-text " + statusClass(o.status) + "\">" + (d ? ('<span class=\\\"status-dot ' + d + ' me-2\\\" aria-hidden=\\\"true\\\"></span>') : '') + escapeHtml(o.status || "") + "</td>";
|
html += "<td class=\"status-text " + statusClass(o.status) + "\">" +
|
||||||
html += "<td>" + (o.error_message || "") + "</td>";
|
(d ? ("<span class=\"status-dot " + d + " me-2\" aria-hidden=\"true\"></span>") : "") +
|
||||||
|
escapeHtml(o.status || "") +
|
||||||
|
"</td>";
|
||||||
|
|
||||||
|
html += "<td>" + escapeHtml(o.error_message || "") + "</td>";
|
||||||
html += "</tr>";
|
html += "</tr>";
|
||||||
}
|
}
|
||||||
html += "</tbody></table></div>";
|
html += "</tbody></table></div>";
|
||||||
@ -383,4 +409,4 @@ function renderObjects(objects) {
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
{% for j in jobs %}
|
{% for j in jobs %}
|
||||||
<tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.job_detail', job_id=j.id) }}'">
|
<tr class="job-row" data-href="{{ url_for('main.job_detail', job_id=j.id) }}" style="cursor: pointer;">
|
||||||
<td>{{ j.customer_name }}</td>
|
<td>{{ j.customer_name }}</td>
|
||||||
<td>{{ j.backup_software }}</td>
|
<td>{{ j.backup_software }}</td>
|
||||||
<td>{{ j.backup_type }}</td>
|
<td>{{ j.backup_type }}</td>
|
||||||
@ -32,4 +32,24 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function onRowClick(e) {
|
||||||
|
// Don't navigate when clicking interactive elements inside the row.
|
||||||
|
if (e.target.closest('a, button, input, select, textarea, label, form')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var href = this.getAttribute('data-href');
|
||||||
|
if (href) {
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('tr.job-row[data-href]').forEach(function (row) {
|
||||||
|
row.addEventListener('click', onRowClick);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -205,9 +205,9 @@
|
|||||||
<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">
|
<div class="mt-2">
|
||||||
<textarea class="form-control form-control-sm" id="rcm_ticket_description" rows="2" placeholder="Description (optional)"></textarea>
|
<input class="form-control form-control-sm" id="rcm_ticket_code" type="text" placeholder="Ticket number (e.g., T20260106.0001)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
|
<div class="mt-2 small text-muted" id="rcm_ticket_status"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
@ -669,25 +669,11 @@ table.addEventListener('change', function (e) {
|
|||||||
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
|
'<span class="fw-semibold">' + escapeHtml(t.ticket_code || '') + '</span>' +
|
||||||
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
'<span class="ms-2 badge ' + (t.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(t.description ? ('<div class="small text-muted mt-1">' + escapeHtml(t.description) + '</div>') : '') +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="d-flex gap-1 flex-shrink-0">' +
|
'<div class="d-flex gap-1 flex-shrink-0">' +
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Edit</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-ticket" data-id="' + t.id + '" ' + (t.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mt-2" data-edit="ticket" style="display:none;">' +
|
|
||||||
'<div class="row g-2">' +
|
|
||||||
'<div class="col-12">' +
|
|
||||||
'<textarea class="form-control form-control-sm" data-field="description" rows="2" placeholder="Description (optional)">' + escapeHtml(t.description || '') + '</textarea>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="col-12 d-flex gap-2">' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-action="save-ticket" data-id="' + t.id + '">Save</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + t.id + '">Cancel</button>' +
|
|
||||||
'<div class="small text-muted align-self-center" data-field="status"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
@ -696,34 +682,21 @@ table.addEventListener('change', function (e) {
|
|||||||
if (remarks.length) {
|
if (remarks.length) {
|
||||||
html += '<div class="mb-2"><strong>Remarks</strong><div class="mt-1">';
|
html += '<div class="mb-2"><strong>Remarks</strong><div class="mt-1">';
|
||||||
remarks.forEach(function (r) {
|
remarks.forEach(function (r) {
|
||||||
var status2 = r.resolved_at ? 'Resolved' : 'Active';
|
var status = r.resolved_at ? 'Resolved' : 'Active';
|
||||||
html += '<div class="mb-2 border rounded p-2" data-alert-type="remark" data-id="' + r.id + '">' +
|
html += '<div class="mb-2 border rounded p-2" data-alert-type="remark" data-id="' + r.id + '">' +
|
||||||
'<div class="d-flex align-items-start justify-content-between gap-2">' +
|
'<div class="d-flex align-items-start justify-content-between gap-2">' +
|
||||||
'<div class="flex-grow-1 min-w-0">' +
|
'<div class="flex-grow-1 min-w-0">' +
|
||||||
'<div class="text-truncate">' +
|
'<div class="text-truncate">' +
|
||||||
'<span class="me-1" title="Remark">💬</span>' +
|
'<span class="me-1" title="Remark">💬</span>' +
|
||||||
'<span class="fw-semibold">Remark</span>' +
|
'<span class="fw-semibold">Remark</span>' +
|
||||||
'<span class="ms-2 badge ' + (r.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status2 + '</span>' +
|
'<span class="ms-2 badge ' + (r.resolved_at ? 'bg-secondary' : 'bg-warning text-dark') + '">' + status + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
|
(r.body ? ('<div class="small text-muted mt-1">' + escapeHtml(r.body) + '</div>') : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="d-flex gap-1 flex-shrink-0">' +
|
'<div class="d-flex gap-1 flex-shrink-0">' +
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="toggle-edit-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Edit</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
'<button type="button" class="btn btn-sm btn-outline-success" data-action="resolve-remark" data-id="' + r.id + '" ' + (r.resolved_at ? 'disabled' : '') + '>Resolve</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mt-2" data-edit="remark" style="display:none;">' +
|
|
||||||
'<div class="row g-2">' +
|
|
||||||
'<div class="col-12">' +
|
|
||||||
'<textarea class="form-control form-control-sm" data-field="body" rows="2" placeholder="Body (required)">' + escapeHtml(r.body || '') + '</textarea>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="col-12 d-flex gap-2">' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-action="save-remark" data-id="' + r.id + '">Save</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-outline-secondary" data-action="cancel-edit" data-id="' + r.id + '">Cancel</button>' +
|
|
||||||
'<div class="small text-muted align-self-center" data-field="status"></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
@ -737,8 +710,6 @@ table.addEventListener('change', function (e) {
|
|||||||
var action = btn.getAttribute('data-action');
|
var action = btn.getAttribute('data-action');
|
||||||
var id = btn.getAttribute('data-id');
|
var id = btn.getAttribute('data-id');
|
||||||
if (!action || !id) return;
|
if (!action || !id) return;
|
||||||
var wrapper = btn.closest('[data-alert-type]');
|
|
||||||
|
|
||||||
if (action === 'resolve-ticket') {
|
if (action === 'resolve-ticket') {
|
||||||
if (!confirm('Mark ticket as resolved?')) return;
|
if (!confirm('Mark ticket as resolved?')) return;
|
||||||
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
apiJson('/api/tickets/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
||||||
@ -749,58 +720,6 @@ table.addEventListener('change', function (e) {
|
|||||||
apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
apiJson('/api/remarks/' + encodeURIComponent(id) + '/resolve', {method: 'POST', body: '{}'})
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
.then(function () { loadAlerts(currentRunId); })
|
||||||
.catch(function (e) { alert(e.message || 'Failed.'); });
|
.catch(function (e) { alert(e.message || 'Failed.'); });
|
||||||
} else if (action === 'toggle-edit-ticket') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var edit = wrapper.querySelector('[data-edit="ticket"]');
|
|
||||||
if (!edit) return;
|
|
||||||
edit.style.display = (edit.style.display === 'none' || !edit.style.display) ? '' : 'none';
|
|
||||||
} else if (action === 'toggle-edit-remark') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var edit2 = wrapper.querySelector('[data-edit="remark"]');
|
|
||||||
if (!edit2) return;
|
|
||||||
edit2.style.display = (edit2.style.display === 'none' || !edit2.style.display) ? '' : 'none';
|
|
||||||
} else if (action === 'cancel-edit') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editAny = wrapper.querySelector('[data-edit]');
|
|
||||||
if (editAny) editAny.style.display = 'none';
|
|
||||||
} else if (action === 'save-ticket') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editT = wrapper.querySelector('[data-edit="ticket"]');
|
|
||||||
if (!editT) return;
|
|
||||||
var descEl = editT.querySelector('[data-field="description"]');
|
|
||||||
var statusEl2 = editT.querySelector('[data-field="status"]');
|
|
||||||
var descVal = descEl ? descEl.value : '';
|
|
||||||
if (statusEl2) statusEl2.textContent = 'Saving...';
|
|
||||||
apiJson('/api/tickets/' + encodeURIComponent(id), {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({description: descVal})
|
|
||||||
})
|
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
|
||||||
.catch(function (e) {
|
|
||||||
if (statusEl2) statusEl2.textContent = e.message || 'Failed.';
|
|
||||||
else alert(e.message || 'Failed.');
|
|
||||||
});
|
|
||||||
} else if (action === 'save-remark') {
|
|
||||||
if (!wrapper) return;
|
|
||||||
var editR = wrapper.querySelector('[data-edit="remark"]');
|
|
||||||
if (!editR) return;
|
|
||||||
var bodyEl = editR.querySelector('[data-field="body"]');
|
|
||||||
var statusEl3 = editR.querySelector('[data-field="status"]');
|
|
||||||
var bodyVal = bodyEl ? bodyEl.value : '';
|
|
||||||
if (!bodyVal || !bodyVal.trim()) {
|
|
||||||
if (statusEl3) statusEl3.textContent = 'Body is required.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (statusEl3) statusEl3.textContent = 'Saving...';
|
|
||||||
apiJson('/api/remarks/' + encodeURIComponent(id), {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({body: bodyVal})
|
|
||||||
})
|
|
||||||
.then(function () { loadAlerts(currentRunId); })
|
|
||||||
.catch(function (e) {
|
|
||||||
if (statusEl3) statusEl3.textContent = e.message || 'Failed.';
|
|
||||||
else alert(e.message || 'Failed.');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -825,8 +744,8 @@ 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 tDesc = document.getElementById('rcm_ticket_description');
|
var tCode = document.getElementById('rcm_ticket_code');
|
||||||
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');
|
||||||
var rStatus = document.getElementById('rcm_remark_status');
|
var rStatus = document.getElementById('rcm_remark_status');
|
||||||
|
|
||||||
@ -838,8 +757,8 @@ 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 (tDesc) tDesc.disabled = disabled;
|
if (tCode) tCode.disabled = disabled;
|
||||||
if (rBody) rBody.disabled = disabled;
|
if (rBody) rBody.disabled = disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__rcmSetCreateDisabled = setDisabled;
|
window.__rcmSetCreateDisabled = setDisabled;
|
||||||
@ -849,15 +768,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 description = tDesc ? tDesc.value : '';
|
var ticket_code = tCode ? (tCode.value || '').trim().toUpperCase() : '';
|
||||||
|
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})
|
||||||
})
|
})
|
||||||
.then(function () {
|
.then(function () {
|
||||||
if (tDesc) tDesc.value = '';
|
if (tCode) tCode.value = '';
|
||||||
if (tStatus) tStatus.textContent = '';
|
if (tStatus) tStatus.textContent = '';
|
||||||
loadAlerts(currentRunId);
|
loadAlerts(currentRunId);
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
|
|||||||
@ -17,19 +17,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="row g-3"> <div class="col-12">
|
<div class="row g-3">
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea class="form-control" name="description" rows="5">{{ ticket.description or '' }}</textarea>
|
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
<div class="col-auto" style="min-width: 260px;">
|
<div class="col-auto" style="min-width: 260px;">
|
||||||
<label class="form-label" for="flt_q">Search</label>
|
<label class="form-label" for="flt_q">Search</label>
|
||||||
<input class="form-control" id="flt_q" name="q" value="{{ q }}" placeholder="ticket code / description / job" />
|
<input class="form-control" id="flt_q" name="q" value="{{ q }}" placeholder="ticket code / job" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@ -89,7 +89,7 @@
|
|||||||
<td class="text-nowrap">{{ t.start_date }}</td>
|
<td class="text-nowrap">{{ t.start_date }}</td>
|
||||||
<td class="text-nowrap">{{ t.resolved_at }}</td>
|
<td class="text-nowrap">{{ t.resolved_at }}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View / Edit</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.ticket_detail', ticket_id=t.id) }}">View</a>
|
||||||
{% if t.active and t.job_id %}
|
{% if t.active and t.job_id %}
|
||||||
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=t.job_id) }}">Job page</a>
|
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=t.job_id) }}">Job page</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -144,7 +144,7 @@
|
|||||||
<td class="text-nowrap">{{ r.start_date }}</td>
|
<td class="text-nowrap">{{ r.start_date }}</td>
|
||||||
<td class="text-nowrap">{{ r.resolved_at }}</td>
|
<td class="text-nowrap">{{ r.resolved_at }}</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View / Edit</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{ url_for('main.remark_detail', remark_id=r.id) }}">View</a>
|
||||||
{% if r.active and r.job_id %}
|
{% if r.active and r.job_id %}
|
||||||
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=r.job_id) }}">Job page</a>
|
<a class="btn btn-sm btn-outline-secondary ms-1" href="{{ url_for('main.job_detail', job_id=r.job_id) }}">Job page</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user