Auto-commit local changes before build (2026-01-06 20:50:50) #58
@ -1 +1 @@
|
|||||||
v20260106-22-ticket-link-multiple-jobs
|
v20260106-24-ticket-scope-resolve-popup
|
||||||
|
|||||||
@ -22,14 +22,19 @@ def api_job_run_alerts(run_id: int):
|
|||||||
db.session.execute(
|
db.session.execute(
|
||||||
text(
|
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
|
FROM tickets t
|
||||||
JOIN ticket_scopes ts ON ts.ticket_id = t.id
|
JOIN ticket_scopes ts ON ts.ticket_id = t.id
|
||||||
WHERE ts.job_id = :job_id
|
WHERE ts.job_id = :job_id
|
||||||
AND t.active_from_date <= :run_date
|
AND t.active_from_date <= :run_date
|
||||||
AND (
|
AND (
|
||||||
t.resolved_at IS NULL
|
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||||
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
|
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
|
ORDER BY t.start_date DESC
|
||||||
"""
|
"""
|
||||||
@ -169,7 +174,7 @@ def api_tickets():
|
|||||||
"active_from_date": str(getattr(t, "active_from_date", "") or ""),
|
"active_from_date": str(getattr(t, "active_from_date", "") or ""),
|
||||||
"start_date": _format_datetime(t.start_date),
|
"start_date": _format_datetime(t.start_date),
|
||||||
"resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "",
|
"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})
|
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
|
return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400
|
||||||
|
|
||||||
existing = Ticket.query.filter_by(ticket_code=ticket_code).first()
|
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:
|
if existing:
|
||||||
ticket = 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:
|
else:
|
||||||
ticket = Ticket(
|
ticket = Ticket(
|
||||||
ticket_code=ticket_code,
|
ticket_code=ticket_code,
|
||||||
@ -251,11 +226,17 @@ def api_tickets():
|
|||||||
resolved_at=None,
|
resolved_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if is_new:
|
||||||
db.session.add(ticket)
|
db.session.add(ticket)
|
||||||
db.session.flush()
|
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(
|
scope = TicketScope(
|
||||||
ticket_id=ticket.id,
|
ticket_id=ticket.id,
|
||||||
scope_type="job",
|
scope_type="job",
|
||||||
@ -265,16 +246,27 @@ def api_tickets():
|
|||||||
job_id=job.id if job else None,
|
job_id=job.id if job else None,
|
||||||
job_name_match=job.job_name if job else None,
|
job_name_match=job.job_name if job else None,
|
||||||
job_name_match_mode="exact",
|
job_name_match_mode="exact",
|
||||||
|
resolved_at=None,
|
||||||
)
|
)
|
||||||
db.session.add(scope)
|
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")
|
link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual")
|
||||||
db.session.add(link)
|
db.session.add(link)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500
|
return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@ -285,8 +277,8 @@ def api_tickets():
|
|||||||
"description": ticket.description or "",
|
"description": ticket.description or "",
|
||||||
"start_date": _format_datetime(ticket.start_date),
|
"start_date": _format_datetime(ticket.start_date),
|
||||||
"active_from_date": str(ticket.active_from_date) if getattr(ticket, "active_from_date", None) else "",
|
"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 "",
|
"resolved_at": "",
|
||||||
"active": getattr(ticket, "resolved_at", None) is None,
|
"active": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -308,9 +300,68 @@ def api_ticket_resolve(ticket_id: int):
|
|||||||
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
return jsonify({"status": "error", "message": "Forbidden."}), 403
|
||||||
|
|
||||||
ticket = Ticket.query.get_or_404(ticket_id)
|
ticket = Ticket.query.get_or_404(ticket_id)
|
||||||
if ticket.resolved_at is None:
|
now = datetime.utcnow()
|
||||||
ticket.resolved_at = datetime.utcnow()
|
|
||||||
|
payload = request.get_json(silent=True) if request.is_json else {}
|
||||||
try:
|
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()
|
db.session.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
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),
|
# 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.
|
# 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 ""):
|
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 redirect(request.referrer or url_for("main.tickets"))
|
||||||
|
|
||||||
return jsonify({"status": "ok", "resolved_at": _format_datetime(ticket.resolved_at)})
|
|
||||||
|
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "resolved_at": _format_datetime(now)})
|
||||||
@main_bp.route("/api/tickets/<int:ticket_id>/link-run", methods=["POST"])
|
@main_bp.route("/api/tickets/<int:ticket_id>/link-run", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
|
|||||||
@ -198,8 +198,8 @@ def job_detail(job_id: int):
|
|||||||
WHERE ts.job_id = :job_id
|
WHERE ts.job_id = :job_id
|
||||||
AND t.active_from_date <= :max_date
|
AND t.active_from_date <= :max_date
|
||||||
AND (
|
AND (
|
||||||
t.resolved_at IS NULL
|
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||||
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date
|
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|||||||
@ -505,8 +505,8 @@ def run_checks_page():
|
|||||||
WHERE ts.job_id = :job_id
|
WHERE ts.job_id = :job_id
|
||||||
AND t.active_from_date <= :run_date
|
AND t.active_from_date <= :run_date
|
||||||
AND (
|
AND (
|
||||||
t.resolved_at IS NULL
|
COALESCE(ts.resolved_at, t.resolved_at) IS NULL
|
||||||
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
|
OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
|
||||||
)
|
)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1351,6 +1351,7 @@ def migrate_object_persistence_tables() -> None:
|
|||||||
job_id INTEGER REFERENCES jobs(id),
|
job_id INTEGER REFERENCES jobs(id),
|
||||||
job_name_match VARCHAR(255),
|
job_name_match VARCHAR(255),
|
||||||
job_name_match_mode VARCHAR(32),
|
job_name_match_mode VARCHAR(32),
|
||||||
|
resolved_at TIMESTAMP,
|
||||||
created_at TIMESTAMP NOT NULL
|
created_at TIMESTAMP NOT NULL
|
||||||
);
|
);
|
||||||
"""))
|
"""))
|
||||||
@ -1364,6 +1365,12 @@ def migrate_object_persistence_tables() -> None:
|
|||||||
UNIQUE(ticket_id, job_run_id)
|
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("""
|
conn.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS remarks (
|
CREATE TABLE IF NOT EXISTS remarks (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@ -397,6 +397,7 @@ class TicketScope(db.Model):
|
|||||||
job_name_match = db.Column(db.String(255))
|
job_name_match = db.Column(db.String(255))
|
||||||
job_name_match_mode = db.Column(db.String(32))
|
job_name_match_mode = db.Column(db.String(32))
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
resolved_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
|
||||||
class TicketJobRun(db.Model):
|
class TicketJobRun(db.Model):
|
||||||
@ -649,4 +650,4 @@ class ReportObjectSummary(db.Model):
|
|||||||
report = db.relationship(
|
report = db.relationship(
|
||||||
"ReportDefinition",
|
"ReportDefinition",
|
||||||
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
|
backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"),
|
||||||
)
|
)
|
||||||
@ -4,6 +4,13 @@
|
|||||||
- Prevented duplicate ticket creation errors when reusing an existing ticket_code.
|
- 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.
|
- 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
|
## v0.1.17
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user