Merge pull request 'Auto-commit local changes before build (2026-01-06 20:50:50)' (#58) from v20260106-24-ticket-scope-resolve-popup into main

Reviewed-on: #58
This commit is contained in:
Ivo Oskamp 2026-01-13 11:22:56 +01:00
commit 0ddcc31e26
7 changed files with 121 additions and 57 deletions

View File

@ -1 +1 @@
v20260106-22-ticket-link-multiple-jobs
v20260106-24-ticket-scope-resolve-popup

View File

@ -22,14 +22,19 @@ def api_job_run_alerts(run_id: int):
db.session.execute(
text(
"""
SELECT t.id, t.ticket_code, t.description, t.start_date, t.resolved_at, t.active_from_date
SELECT t.id,
t.ticket_code,
t.description,
t.start_date,
COALESCE(ts.resolved_at, t.resolved_at) AS resolved_at,
t.active_from_date
FROM tickets t
JOIN ticket_scopes ts ON ts.ticket_id = t.id
WHERE ts.job_id = :job_id
AND t.active_from_date <= :run_date
AND (
t.resolved_at IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
)
ORDER BY t.start_date DESC
"""
@ -169,7 +174,7 @@ def api_tickets():
"active_from_date": str(getattr(t, "active_from_date", "") or ""),
"start_date": _format_datetime(t.start_date),
"resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "",
"active": t.resolved_at is None,
"active": (t.resolved_at is None and TicketScope.query.filter_by(ticket_id=t.id, resolved_at=None).first() is not None),
}
)
return jsonify({"status": "ok", "tickets": items})
@ -205,42 +210,12 @@ def api_tickets():
return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400
existing = Ticket.query.filter_by(ticket_code=ticket_code).first()
is_new = existing is None
# If the ticket already exists, link it to this job/run (a single ticket number can apply to multiple jobs).
# Create new ticket if it doesn't exist; otherwise reuse the existing one so the same
# ticket number can be linked to multiple jobs and job runs.
if existing:
ticket = existing
try:
# Ensure there is at least one job scope for this ticket/job combination.
if job and job.id:
scope = TicketScope.query.filter_by(
ticket_id=ticket.id,
scope_type="job",
job_id=job.id,
).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id,
backup_software=job.backup_software,
backup_type=job.backup_type,
job_id=job.id,
job_name_match=job.job_name,
job_name_match_mode="exact",
)
db.session.add(scope)
# Link the ticket to this specific run (idempotent due to unique constraint).
existing_link = TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first()
if not existing_link:
link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual")
db.session.add(link)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to link ticket."}), 500
else:
ticket = Ticket(
ticket_code=ticket_code,
@ -251,11 +226,17 @@ def api_tickets():
resolved_at=None,
)
try:
try:
if is_new:
db.session.add(ticket)
db.session.flush()
# Minimal scope from job
# Ensure a scope exists for this job so alerts/popups can show the ticket code.
scope = None
if job and job.id:
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
@ -265,16 +246,27 @@ def api_tickets():
job_id=job.id if job else None,
job_name_match=job.job_name if job else None,
job_name_match_mode="exact",
resolved_at=None,
)
db.session.add(scope)
else:
# Re-open this ticket for this job if it was previously resolved for this scope.
scope.resolved_at = None
scope.customer_id = job.customer_id if job else scope.customer_id
scope.backup_software = job.backup_software if job else scope.backup_software
scope.backup_type = job.backup_type if job else scope.backup_type
scope.job_name_match = job.job_name if job else scope.job_name_match
scope.job_name_match_mode = "exact"
# Link ticket to this job run (idempotent)
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first():
link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual")
db.session.add(link)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500
return jsonify(
{
@ -285,8 +277,8 @@ def api_tickets():
"description": ticket.description or "",
"start_date": _format_datetime(ticket.start_date),
"active_from_date": str(ticket.active_from_date) if getattr(ticket, "active_from_date", None) else "",
"resolved_at": _format_datetime(ticket.resolved_at) if getattr(ticket, "resolved_at", None) else "",
"active": getattr(ticket, "resolved_at", None) is None,
"resolved_at": "",
"active": True,
},
}
)
@ -308,9 +300,68 @@ def api_ticket_resolve(ticket_id: int):
return jsonify({"status": "error", "message": "Forbidden."}), 403
ticket = Ticket.query.get_or_404(ticket_id)
if ticket.resolved_at is None:
ticket.resolved_at = datetime.utcnow()
now = datetime.utcnow()
payload = request.get_json(silent=True) if request.is_json else {}
try:
run_id = int((payload or {}).get("job_run_id") or 0)
except Exception:
run_id = 0
# Job-scoped resolve (from popups / job details): resolve only for the job of the provided job_run_id
if run_id > 0:
run = JobRun.query.get(run_id)
if not run:
return jsonify({"status": "error", "message": "Job run not found."}), 404
job = Job.query.get(run.job_id) if run else None
job_id = job.id if job else None
try:
scope = None
if job_id:
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job_id).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id if job else None,
backup_software=job.backup_software if job else None,
backup_type=job.backup_type if job else None,
job_id=job_id,
job_name_match=job.job_name if job else None,
job_name_match_mode="exact",
resolved_at=now,
)
db.session.add(scope)
else:
if scope.resolved_at is None:
scope.resolved_at = now
# Keep the audit link to the run (idempotent)
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first():
db.session.add(TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual"))
# If all scopes are resolved, also resolve the ticket globally (so the central list shows it as resolved)
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
if open_scope is None and ticket.resolved_at is None:
ticket.resolved_at = now
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to resolve ticket."}), 500
return jsonify({"status": "ok", "resolved_at": _format_datetime(now)})
# Global resolve (from central ticket list): resolve ticket and all scopes
if ticket.resolved_at is None:
ticket.resolved_at = now
try:
# Resolve any still-open scopes
TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).update({"resolved_at": now})
db.session.commit()
except Exception as exc:
db.session.rollback()
@ -319,11 +370,9 @@ def api_ticket_resolve(ticket_id: int):
# If this endpoint is called from a regular HTML form submit (e.g. Tickets/Remarks page),
# redirect back instead of showing raw JSON in the browser.
if not request.is_json and "application/json" not in (request.headers.get("Accept") or ""):
return redirect(request.referrer or url_for("main.tickets_page"))
return jsonify({"status": "ok", "resolved_at": _format_datetime(ticket.resolved_at)})
return redirect(request.referrer or url_for("main.tickets"))
return jsonify({"status": "ok", "resolved_at": _format_datetime(now)})
@main_bp.route("/api/tickets/<int:ticket_id>/link-run", methods=["POST"])
@login_required
@roles_required("admin", "operator", "viewer")

View File

@ -198,8 +198,8 @@ def job_detail(job_id: int):
WHERE ts.job_id = :job_id
AND t.active_from_date <= :max_date
AND (
t.resolved_at IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date
)
"""
),

View File

@ -505,8 +505,8 @@ def run_checks_page():
WHERE ts.job_id = :job_id
AND t.active_from_date <= :run_date
AND (
t.resolved_at IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
)
LIMIT 1
"""

View File

@ -1351,6 +1351,7 @@ def migrate_object_persistence_tables() -> None:
job_id INTEGER REFERENCES jobs(id),
job_name_match VARCHAR(255),
job_name_match_mode VARCHAR(32),
resolved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
);
"""))
@ -1364,6 +1365,12 @@ def migrate_object_persistence_tables() -> None:
UNIQUE(ticket_id, job_run_id)
);
"""))
# Ensure scope-level resolution exists for per-job ticket resolving
conn.execute(text("ALTER TABLE ticket_scopes ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMP"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_ticket_id ON ticket_scopes (ticket_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_job_id ON ticket_scopes (job_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_resolved_at ON ticket_scopes (resolved_at)"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS remarks (
id SERIAL PRIMARY KEY,

View File

@ -397,6 +397,7 @@ class TicketScope(db.Model):
job_name_match = db.Column(db.String(255))
job_name_match_mode = db.Column(db.String(32))
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
resolved_at = db.Column(db.DateTime)
class TicketJobRun(db.Model):
@ -649,4 +650,4 @@ class ReportObjectSummary(db.Model):
report = db.relationship(
"ReportDefinition",
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
)
)

View File

@ -4,6 +4,13 @@
- Prevented duplicate ticket creation errors when reusing an existing ticket_code.
- Ensured tickets are reused and linked instead of rejected when already present in the system.
## v20260106-24-ticket-scope-resolve-popup
- Fixed missing ticket number display in job and run popups by always creating or reusing a ticket scope when linking an existing ticket to a job.
- Updated ticket resolution logic to support per-job resolution when resolving from a job or run context.
- Ensured resolving a ticket from the central Tickets view resolves the ticket globally and closes all associated job scopes.
- Updated ticket active status determination to be based on open job scopes, allowing the same ticket number to remain active for other jobs when applicable.
================================================================================================================================================
## v0.1.17