From 299bf2ca05e018f2b563da2f65311eda3b650d7e Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 6 Apr 2026 18:02:16 -0400 Subject: [PATCH] 04/06 adding some additional functions --- app/__init__.py | 45 +++ app/models.py | 61 +++- app/routes/admin.py | 81 ++++- app/routes/tickets.py | 198 +++++++++++- app/services/notification_service.py | 50 ++- app/services/sla_service.py | 294 ++++++++++++++++++ app/templates/admin/canned_response_edit.html | 71 +++++ app/templates/admin/canned_responses.html | 63 ++++ app/templates/auth/profile.html | 124 +++++++- app/templates/base.html | 3 + app/templates/tickets/detail.html | 153 +++++++++ config/config.py | 8 + 12 files changed, 1142 insertions(+), 9 deletions(-) create mode 100644 app/services/sla_service.py create mode 100644 app/templates/admin/canned_response_edit.html create mode 100644 app/templates/admin/canned_responses.html diff --git a/app/__init__.py b/app/__init__.py index a6dcdf0..9b6839d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -144,14 +144,59 @@ def create_app(config_name=None): _seed_admin(app) _seed_settings() + # ── SLA background scheduler ────────────────────────────────────────────── + # APScheduler runs inside the gunicorn worker process (single-worker + # eventlet setup), so no cross-process coordination is needed. + # The scheduler is only started in the main process — not during Flask's + # reloader child process — to prevent duplicate job execution. + _start_sla_scheduler(app) + return app +def _start_sla_scheduler(app): + """Start the APScheduler background job that checks for SLA breaches. + + Safe to call on every app startup: the scheduler is idempotent and + jobstore deduplication prevents double-registration on hot-reloads. + Under gunicorn with preload_app=False each worker calls create_app() + once, so there is exactly one scheduler per worker. + """ + # Skip inside Flask's reloader subprocess (identified by the env var it sets). + import os as _os + if _os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + # Reloader is active — the child process will start its own scheduler. + return + + try: + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.triggers.interval import IntervalTrigger + from app.services.sla_service import check_sla_breaches + + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + func = check_sla_breaches, + trigger = IntervalTrigger(minutes=30), + id = 'sla_check', + name = 'SLA Breach Check', + replace_existing = True, + args = [app], + ) + scheduler.start() + app.logger.info('[SLA] APScheduler started — SLA breach check every 30 minutes') + except Exception as exc: + app.logger.error(f'[SLA] Failed to start APScheduler: {exc}') + + def _seed_settings(): """Ensure all required system settings exist with safe defaults.""" from app.models import SystemSetting defaults = [ ('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'), + ('sla_critical_hours', '4', 'Hours before a CRITICAL ticket is considered overdue'), + ('sla_high_hours', '8', 'Hours before a HIGH ticket is considered overdue'), + ('sla_medium_hours', '48', 'Hours before a MEDIUM ticket is considered overdue'), + ('sla_low_hours', '120', 'Hours before a LOW ticket is considered overdue'), ('app_name', 'TechDesk', 'Application name shown in the sidebar and page titles'), ('app_subtitle', 'IT Helpdesk System', 'Subtitle shown below the app name in the sidebar'), ('company_name', '', 'Company name shown on the login and register pages'), diff --git a/app/models.py b/app/models.py index 19e288a..0278c5e 100644 --- a/app/models.py +++ b/app/models.py @@ -359,4 +359,63 @@ class SystemSetting(db.Model): return row def __repr__(self): - return f'' \ No newline at end of file + return f'' + +class TicketLink(db.Model): + """Bidirectional link between two related tickets. + + A single row represents a symmetric relationship — if TKT-A is linked to + TKT-B, the link is stored once with ticket_id < linked_ticket_id (enforced + at the application layer). Queries must search both columns to find all + links for a given ticket. + + Use cases: duplicate tickets, related outages, follow-up work items. + """ + __tablename__ = 'ticket_links' + + id = db.Column(db.Integer, primary_key=True) + ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) + linked_ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) + link_type = db.Column(db.String(20), default='related', nullable=False) + # link_type values: 'related' | 'duplicate' | 'follow_up' + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + ticket = db.relationship('Ticket', foreign_keys=[ticket_id], + backref=db.backref('links_as_source', lazy='dynamic', + cascade='all, delete-orphan')) + linked_ticket = db.relationship('Ticket', foreign_keys=[linked_ticket_id], + backref=db.backref('links_as_target', lazy='dynamic', + cascade='all, delete-orphan')) + creator = db.relationship('User', foreign_keys=[created_by]) + + __table_args__ = ( + db.UniqueConstraint('ticket_id', 'linked_ticket_id', name='uq_ticket_link'), + ) + + def __repr__(self): + return f'' + + +class CannedResponse(db.Model): + """Pre-written IT reply templates for common ticket scenarios. + + IT staff can insert these into comment boxes with one click, saving + typing time for repetitive responses like 'please restart and confirm', + 'escalating to vendor', or 'issue resolved after patch applied'. + """ + __tablename__ = 'canned_responses' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(120), nullable=False) + body = db.Column(db.Text, nullable=False) + category = db.Column(db.String(50)) # optional grouping label + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + creator = db.relationship('User', foreign_keys=[created_by]) + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index 77f158a..ca596b9 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -11,7 +11,7 @@ from werkzeug.utils import secure_filename import bleach from app import db, limiter from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase, - KBAttachment, UserRole, TicketStatus) + KBAttachment, UserRole, TicketStatus, CannedResponse) from app.services.log_service import log_action from app.services.validation_service import validate_password, validate_file @@ -851,6 +851,85 @@ def export_tickets(): ) +# ─── Canned Responses Management ───────────────────────────────────────────── + +@admin_bp.route('/canned-responses') +@login_required +@it_required +def canned_responses(): + """List all canned responses.""" + items = CannedResponse.query.order_by( + CannedResponse.category, CannedResponse.title + ).all() + categories = sorted({r.category for r in items if r.category}) + return render_template('admin/canned_responses.html', + items=items, categories=categories) + + +@admin_bp.route('/canned-responses/new', methods=['GET', 'POST']) +@login_required +@it_required +def canned_response_new(): + if request.method == 'POST': + title = request.form.get('title', '').strip() + body = request.form.get('body', '').strip() + category = request.form.get('category', '').strip() + if not title or not body: + flash('Title and body are required.', 'danger') + return render_template('admin/canned_response_edit.html', item=None) + item = CannedResponse( + title = title, + body = body, + category = category or None, + created_by = current_user.id, + ) + db.session.add(item) + db.session.flush() + log_action(current_user.id, 'canned_response_create', 'canned_response', item.id, + f'title={title}') + db.session.commit() + logger.info(f'[CANNED RESPONSE CREATE] id={item.id} by user_id={current_user.id}') + flash('Canned response created.', 'success') + return redirect(url_for('admin.canned_responses')) + return render_template('admin/canned_response_edit.html', item=None) + + +@admin_bp.route('/canned-responses//edit', methods=['GET', 'POST']) +@login_required +@it_required +def canned_response_edit(item_id): + item = db.session.get(CannedResponse, item_id) or abort(404) + if request.method == 'POST': + item.title = request.form.get('title', item.title).strip() + item.body = request.form.get('body', item.body).strip() + item.category = request.form.get('category', '').strip() or None + item.is_active = bool(request.form.get('is_active')) + if not item.title or not item.body: + flash('Title and body are required.', 'danger') + return render_template('admin/canned_response_edit.html', item=item) + log_action(current_user.id, 'canned_response_edit', 'canned_response', item.id, + f'title={item.title}') + db.session.commit() + logger.info(f'[CANNED RESPONSE EDIT] id={item.id} by user_id={current_user.id}') + flash('Canned response updated.', 'success') + return redirect(url_for('admin.canned_responses')) + return render_template('admin/canned_response_edit.html', item=item) + + +@admin_bp.route('/canned-responses//delete', methods=['POST']) +@login_required +@it_required +def canned_response_delete(item_id): + item = db.session.get(CannedResponse, item_id) or abort(404) + log_action(current_user.id, 'canned_response_delete', 'canned_response', item.id, + f'title={item.title}') + logger.info(f'[CANNED RESPONSE DELETE] id={item.id} by user_id={current_user.id}') + db.session.delete(item) + db.session.commit() + flash('Canned response deleted.', 'success') + return redirect(url_for('admin.canned_responses')) + + def _roles(): return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 2a93d94..d99439d 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -9,12 +9,13 @@ from werkzeug.utils import secure_filename from app import db from app.models import (Ticket, Comment, Attachment, Notification, TicketStatus, TicketPriority, TicketCategory, - User, UserRole, KnowledgeBase) + User, UserRole, KnowledgeBase, TicketLink, CannedResponse) from app.services.notification_service import ( notify_new_ticket, notify_status_change, notify_comment_added, notify_assignment, ) from app.services.log_service import log_action, log_ticket_history +from app.services.sla_service import clear_sla_notification from app.services.validation_service import validate_file, render_comment_body tickets_bp = Blueprint('tickets', __name__) @@ -162,6 +163,7 @@ def create_ticket(): asset_tag = asset_tag, created_by_id = current_user.id, status = TicketStatus.OPEN, + due_date = _sla_due_date(priority), # auto-set SLA deadline ) ticket.ticket_number = ticket.generate_ticket_number() db.session.add(ticket) @@ -250,6 +252,7 @@ def create_ticket_behalf(): created_by_id = employee.id, # ticket belongs to the employee created_by_staff_id = current_user.id, # IT staff who filed it status = TicketStatus.OPEN, + due_date = _sla_due_date(priority), # auto-set SLA deadline ) ticket.ticket_number = ticket.generate_ticket_number() db.session.add(ticket) @@ -421,10 +424,28 @@ def ticket_detail(ticket_id): history = ticket.history.order_by('changed_at').all() + # Collect all links for this ticket from both directions + links_src = ticket.links_as_source.all() + links_tgt = ticket.links_as_target.all() + # Build a unified list of (link_obj, other_ticket) tuples for the template + linked_tickets = ( + [(lnk, lnk.linked_ticket) for lnk in links_src] + + [(lnk, lnk.ticket) for lnk in links_tgt] + ) + + # Canned responses for IT staff comment form + canned_responses = [] + if current_user.is_it_staff: + canned_responses = CannedResponse.query.filter_by(is_active=True).order_by( + CannedResponse.category, CannedResponse.title + ).all() + return render_template('tickets/detail.html', ticket=ticket, comments=comments, it_staff=it_staff, history=history, statuses=_statuses(), priorities=_priorities(), + linked_tickets=linked_tickets, + canned_responses=canned_responses, ) @@ -456,8 +477,12 @@ def update_ticket(ticket_id): changes.append(f'status: {old_status} → {new_status}') if new_status == TicketStatus.RESOLVED: ticket.resolved_at = datetime.utcnow() + clear_sla_notification(ticket.id) # remove from breach suppression list elif new_status == TicketStatus.CLOSED: ticket.closed_at = datetime.utcnow() + clear_sla_notification(ticket.id) # remove from breach suppression list + elif new_status == TicketStatus.OPEN: + clear_sla_notification(ticket.id) # re-opened — allow fresh breach alerts if new_priority != old_priority: ticket.priority = new_priority @@ -652,8 +677,179 @@ def kb_article(article_id): return render_template('tickets/kb_article.html', article=article) +# ─── Ticket Link / Unlink ──────────────────────────────────────────────────── + +@tickets_bp.route('/tickets//link', methods=['POST']) +@login_required +def link_ticket(ticket_id): + """Create a bidirectional link between two tickets (IT staff only).""" + if not current_user.is_it_staff: + abort(403) + + ticket = db.session.get(Ticket, ticket_id) or abort(404) + other_id = request.form.get('linked_ticket_id', type=int) + link_type = request.form.get('link_type', 'related') + + if not other_id: + flash('Please specify a ticket to link.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + if other_id == ticket_id: + flash('A ticket cannot be linked to itself.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + other = db.session.get(Ticket, other_id) + if not other: + flash(f'Ticket #{other_id} not found.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + # Enforce canonical ordering (smaller id first) for the uniqueness constraint + a, b = sorted([ticket_id, other_id]) + existing = TicketLink.query.filter_by(ticket_id=a, linked_ticket_id=b).first() + if existing: + flash(f'These tickets are already linked.', 'warning') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + if link_type not in ('related', 'duplicate', 'follow_up'): + link_type = 'related' + + link = TicketLink( + ticket_id = a, + linked_ticket_id = b, + link_type = link_type, + created_by = current_user.id, + ) + db.session.add(link) + log_action(current_user.id, 'ticket_link_create', 'ticket', ticket_id, + f'linked_to={other_id} type={link_type}') + db.session.commit() + logger.info(f'[TICKET LINK] ticket_id={a} linked_ticket_id={b} ' + f'type={link_type} by user_id={current_user.id}') + flash(f'Linked to {other.ticket_number} ({link_type.replace("_", " ")}).', 'success') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + +@tickets_bp.route('/tickets//unlink/', methods=['POST']) +@login_required +def unlink_ticket(ticket_id, link_id): + """Remove a ticket link (IT staff only).""" + if not current_user.is_it_staff: + abort(403) + link = db.session.get(TicketLink, link_id) or abort(404) + if link.ticket_id != ticket_id and link.linked_ticket_id != ticket_id: + abort(403) + log_action(current_user.id, 'ticket_link_delete', 'ticket', ticket_id, + f'link_id={link_id} removed') + logger.info(f'[TICKET UNLINK] link_id={link_id} ticket_id={ticket_id} ' + f'by user_id={current_user.id}') + db.session.delete(link) + db.session.commit() + flash('Link removed.', 'success') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + +# ─── Ticket Re-open ─────────────────────────────────────────────────────────── + +@tickets_bp.route('/tickets//reopen', methods=['POST']) +@login_required +def reopen_ticket(ticket_id): + """Allow the ticket creator to re-open a resolved or closed ticket. + + A re-open creates a comment with the employee's explanation, resets the + ticket status to Open, and notifies all IT staff so the ticket resurfaces + in the queue without being lost. + """ + ticket = db.session.get(Ticket, ticket_id) or abort(404) + + # Only the original creator (or IT staff) can re-open + if not current_user.is_it_staff and ticket.created_by_id != current_user.id: + abort(403) + + if ticket.status not in (TicketStatus.RESOLVED, TicketStatus.CLOSED): + flash('Only resolved or closed tickets can be re-opened.', 'warning') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + reason = request.form.get('reopen_reason', '').strip() + if not reason: + flash('Please describe why the issue has returned.', 'danger') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + old_status = ticket.status + ticket.status = TicketStatus.OPEN + ticket.resolved_at = None # clear resolved timestamp + + # Clear SLA suppression so the re-opened ticket can breach again if neglected + from app.services.sla_service import clear_sla_notification + clear_sla_notification(ticket.id) + + # Post a system comment documenting the re-open + reopen_body = render_comment_body( + f'**Issue has returned — ticket re-opened**\n\n{reason}' + ) + comment = Comment( + ticket_id = ticket.id, + author_id = current_user.id, + body = reopen_body, + is_internal= False, + ) + db.session.add(comment) + db.session.flush() + + log_ticket_history(ticket, 'status', old_status, TicketStatus.OPEN, current_user.id) + log_action(current_user.id, 'ticket_reopen', 'ticket', ticket.id, + f'previous_status={old_status} reason_len={len(reason)}') + db.session.commit() + logger.info(f'[TICKET REOPEN] ticket_id={ticket.id} ' + f'previous_status={old_status} by user_id={current_user.id}') + + # Notify IT staff that the ticket has been re-opened + notify_status_change(ticket, old_status, current_user) + flash('Ticket re-opened. IT staff have been notified.', 'success') + return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id)) + + +# ─── Canned Responses API (IT only) ────────────────────────────────────────── + +@tickets_bp.route('/canned-responses') +@login_required +def get_canned_responses(): + """Return active canned responses as JSON for the comment form picker.""" + if not current_user.is_it_staff: + abort(403) + responses = CannedResponse.query.filter_by(is_active=True).order_by( + CannedResponse.category, CannedResponse.title + ).all() + return __import__('flask').jsonify({'responses': [ + { + 'id' : r.id, + 'title' : r.title, + 'body' : r.body, + 'category': r.category or '', + } + for r in responses + ]}) + + # ─── Helpers ───────────────────────────────────────────────────────────────── +def _sla_due_date(priority: str) -> 'datetime': + """Return the SLA due datetime for a ticket based on its priority. + + Thresholds are read from SystemSetting so admins can tune them in-app + without a code deploy. Falls back to config values if settings are absent. + """ + from app.models import SystemSetting + hours_map = { + TicketPriority.CRITICAL: int(SystemSetting.get('sla_critical_hours', current_app.config.get('SLA_CRITICAL_HOURS', 4))), + TicketPriority.HIGH: int(SystemSetting.get('sla_high_hours', current_app.config.get('SLA_HIGH_HOURS', 8))), + TicketPriority.MEDIUM: int(SystemSetting.get('sla_medium_hours', current_app.config.get('SLA_MEDIUM_HOURS', 48))), + TicketPriority.LOW: int(SystemSetting.get('sla_low_hours', current_app.config.get('SLA_LOW_HOURS', 120))), + } + from datetime import timedelta + hours = hours_map.get(priority, 48) + return datetime.utcnow() + timedelta(hours=hours) + + def _statuses(): return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS, TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED] diff --git a/app/services/notification_service.py b/app/services/notification_service.py index 7dafc64..28bb9b1 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -335,12 +335,18 @@ def notify_comment_added(comment): def notify_assignment(ticket, assigned_by): - """Notify newly assigned IT staff member.""" + """Notify newly assigned IT staff member AND the ticket creator (employee). + + The employee who submitted the ticket (or on whose behalf it was filed) + receives a confirmation that their issue has been picked up, giving + them visibility without requiring them to poll the ticket page. + """ if not ticket.assigned_to_id: return - base_url = current_app.config.get('APP_BASE_URL', '') + base_url = current_app.config.get('APP_BASE_URL', '') ticket_url = f"{base_url}/tickets/{ticket.id}" + # ── Notify the IT staff assignee ────────────────────────────────────────── create_notification( user_id = ticket.assigned_to_id, notif_type= NotificationType.TICKET_ASSIGNED, @@ -349,7 +355,6 @@ def notify_assignment(ticket, assigned_by): ticket_id = ticket.id, link = f'/tickets/{ticket.id}', ) - db.session.commit() if ticket.assignee and ticket.assignee.email_notif: html = render_template_string(_STATUS_UPDATE_EMAIL, ticket_number = ticket.ticket_number, @@ -362,4 +367,41 @@ def notify_assignment(ticket, assigned_by): [ticket.assignee.email], html, ) - logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}') \ No newline at end of file + + # ── Notify the ticket creator (employee) ────────────────────────────────── + # Only notify if the creator is not the assignee — avoids a redundant + # self-notification when IT staff file and assign their own tickets. + if ticket.created_by_id != ticket.assigned_to_id: + assignee_name = ticket.assignee.full_name if ticket.assignee else 'an IT staff member' + employee_msg = ( + f'Your ticket "{ticket.title}" has been picked up by {assignee_name}. ' + f'You will be notified as soon as there is an update.' + ) + create_notification( + user_id = ticket.created_by_id, + notif_type= NotificationType.TICKET_ASSIGNED, + title = f'Ticket {ticket.ticket_number} — Now Being Worked On', + message = employee_msg, + ticket_id = ticket.id, + link = f'/tickets/{ticket.id}', + ) + creator = db.session.get(User, ticket.created_by_id) + if creator and creator.email_notif: + html = render_template_string(_STATUS_UPDATE_EMAIL, + ticket_number = ticket.ticket_number, + title = ticket.title, + message = employee_msg, + ticket_url = ticket_url, + ) + send_email( + f'[Ticket Update] {ticket.ticket_number} — Now Being Worked On', + [creator.email], + html, + ) + + db.session.commit() + logger.info( + f'[TICKET ASSIGN] ticket_id={ticket.id} ' + f'assigned_to={ticket.assigned_to_id} by={assigned_by.id} ' + f'employee_notified={ticket.created_by_id != ticket.assigned_to_id}' + ) diff --git a/app/services/sla_service.py b/app/services/sla_service.py new file mode 100644 index 0000000..c097107 --- /dev/null +++ b/app/services/sla_service.py @@ -0,0 +1,294 @@ +""" +SLA Service — breach detection and automatic due-date enforcement. + +Responsibilities +---------------- +1. check_sla_breaches(app) + Called every 30 minutes by APScheduler. Finds all open/in-progress + tickets whose due_date has passed and whose SLA breach has not yet + been notified. Sends in-app notifications to the assignee (if any) + and to every active IT admin, then stamps the ticket so repeat + notifications are suppressed until the ticket is updated. + +2. set_due_date(ticket) + Convenience helper called by the ticket-creation routes so due dates + are always derived from the same single source of truth. + +Notification suppression +------------------------ +A dedicated SystemSetting key sla_notified_tickets stores a +comma-separated list of ticket IDs that have already received a breach +notification. When a ticket is resolved or closed the ID is removed +from the list so the suppression does not persist across re-opens +(edge case: ticket re-opened after resolution — unlikely but handled). + +Design notes +------------ +- Runs inside the gunicorn worker process (no separate process needed). +- Uses app.app_context() so SQLAlchemy sessions are properly scoped. +- All DB writes commit independently from the main request cycle. +- Errors are logged but never raised — a scheduler failure must not + bring down the web process. +""" + +import logging +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# ── Priority → SLA hours mapping (fallback if SystemSetting is absent) ──────── +_DEFAULT_SLA_HOURS = { + 'critical': 4, + 'high': 8, + 'medium': 48, + 'low': 120, +} + +_SETTING_KEYS = { + 'critical': 'sla_critical_hours', + 'high': 'sla_high_hours', + 'medium': 'sla_medium_hours', + 'low': 'sla_low_hours', +} + + +def _get_sla_hours(priority: str, app) -> int: + """Return SLA hours for *priority*, reading from SystemSetting first.""" + from app.models import SystemSetting + key = _SETTING_KEYS.get(priority, 'sla_medium_hours') + config_key = f'SLA_{priority.upper()}_HOURS' + default = app.config.get(config_key, _DEFAULT_SLA_HOURS.get(priority, 48)) + raw = SystemSetting.get(key) + try: + return int(raw) if raw is not None else int(default) + except (ValueError, TypeError): + return int(default) + + +def set_due_date(ticket, app): + """Set ticket.due_date from SLA config if not already set. + + Callers are responsible for committing after calling this function. + """ + if ticket.due_date: + return # already set — respect manual override + hours = _get_sla_hours(ticket.priority, app) + ticket.due_date = datetime.utcnow() + timedelta(hours=hours) + + +# ── SLA breach notification email template ──────────────────────────────────── + +_SLA_BREACH_EMAIL = """ + +
+
+

⏰ SLA Breach — Action Required

+
+
+

+ The following ticket has exceeded its SLA response target and requires immediate attention. +

+ + + + + + + + + + + + + + + + +
Ticket #{{ ticket_number }}
Title{{ title }}
Priority + {{ priority }} +
Status{{ status }}
Assigned To{{ assigned_to }}
Due Date{{ due_date }} (overdue)
+ + View & Action Ticket + +
+
+ IT Helpdesk System • This is an automated SLA alert. +
+
+ +""" + + +def _priority_color(priority: str) -> str: + return { + 'low': '#28a745', + 'medium': '#ffc107', + 'high': '#dc3545', + 'critical': '#7f1d1d', + }.get(priority, '#6c757d') + + +def check_sla_breaches(app): + """Scheduled job: find overdue tickets and notify responsible parties. + + Safe to call repeatedly — already-notified tickets are suppressed via + the sla_notified_tickets SystemSetting key. The suppression list is + cleared for a ticket when it transitions to resolved/closed (handled by + the update_ticket route clearing it on status change) or when the ticket + is re-opened, ensuring fresh notifications if the issue resurfaces. + """ + with app.app_context(): + try: + _run_sla_check(app) + except Exception as exc: + logger.error(f'[SLA] Unhandled error in check_sla_breaches: {exc}', exc_info=True) + + +def _run_sla_check(app): + from app import db + from app.models import ( + Ticket, TicketStatus, User, UserRole, + NotificationType, SystemSetting, + ) + from app.services.notification_service import create_notification, send_email + from flask import render_template_string + + now = datetime.utcnow() + + # ── Load suppression list ────────────────────────────────────────────────── + raw = SystemSetting.get('sla_notified_tickets', '') + already_notified = set(int(x) for x in raw.split(',') if x.strip().isdigit()) + + # ── Query overdue open tickets ───────────────────────────────────────────── + overdue = Ticket.query.filter( + Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]), + Ticket.due_date.isnot(None), + Ticket.due_date < now, + ).all() + + if not overdue: + logger.info(f'[SLA] Check complete — no overdue tickets at {now.strftime("%Y-%m-%d %H:%M")}') + return + + base_url = app.config.get('APP_BASE_URL', '') + it_dept_email = app.config.get('IT_DEPT_EMAIL', '') + + newly_notified = [] + + for ticket in overdue: + if ticket.id in already_notified: + continue # already sent — skip + + ticket_url = f'{base_url}/tickets/{ticket.id}' + overdue_mins = int((now - ticket.due_date).total_seconds() / 60) + overdue_label = ( + f'{overdue_mins // 60}h {overdue_mins % 60}m' + if overdue_mins >= 60 else f'{overdue_mins}m' + ) + + logger.warning( + f'[SLA BREACH] ticket_id={ticket.id} number={ticket.ticket_number} ' + f'priority={ticket.priority} overdue_by={overdue_label} ' + f'due={ticket.due_date.strftime("%Y-%m-%d %H:%M")} ' + f'assigned_to={ticket.assigned_to_id}' + ) + + # Build the email + html = render_template_string( + _SLA_BREACH_EMAIL, + ticket_number = ticket.ticket_number, + title = ticket.title, + priority = ticket.priority.upper(), + priority_color = _priority_color(ticket.priority), + status = ticket.status.replace('_', ' ').title(), + assigned_to = ticket.assignee.full_name if ticket.assignee else 'Unassigned', + due_date = ticket.due_date.strftime('%b %d, %Y %H:%M UTC'), + ticket_url = ticket_url, + ) + + subject = ( + f'[SLA BREACH] {ticket.ticket_number} — {ticket.priority.upper()} ' + f'ticket overdue by {overdue_label}' + ) + + notif_title = f'⏰ SLA Breach: {ticket.ticket_number}' + notif_message = ( + f'{ticket.priority.upper()} priority ticket "{ticket.title}" ' + f'is overdue by {overdue_label}.' + ) + + notified_user_ids = set() + + # Notify assignee (in-app + email) + if ticket.assigned_to_id: + create_notification( + user_id = ticket.assigned_to_id, + notif_type= NotificationType.TICKET_UPDATED, + title = notif_title, + message = notif_message, + ticket_id = ticket.id, + link = f'/tickets/{ticket.id}', + ) + if ticket.assignee and ticket.assignee.email_notif: + send_email(subject, [ticket.assignee.email], html) + notified_user_ids.add(ticket.assigned_to_id) + + # Notify all active IT admins (in-app + IT dept email) + admins = User.query.filter( + User.role == UserRole.ADMIN, + User.is_active == True, + ).all() + for admin in admins: + if admin.id not in notified_user_ids: + create_notification( + user_id = admin.id, + notif_type= NotificationType.TICKET_UPDATED, + title = notif_title, + message = notif_message, + ticket_id = ticket.id, + link = f'/tickets/{ticket.id}', + ) + notified_user_ids.add(admin.id) + + # One email to the IT department inbox + if it_dept_email: + send_email(subject, [it_dept_email], html) + + newly_notified.append(ticket.id) + db.session.commit() + + # ── Update suppression list ──────────────────────────────────────────────── + if newly_notified: + updated = already_notified | set(newly_notified) + SystemSetting.set( + 'sla_notified_tickets', + ','.join(str(i) for i in sorted(updated)), + 'Comma-separated ticket IDs that have received SLA breach notifications', + ) + db.session.commit() + logger.info( + f'[SLA] Notified {len(newly_notified)} breach(es): ' + f'{[str(i) for i in newly_notified]}' + ) + + +def clear_sla_notification(ticket_id: int): + """Remove a ticket from the SLA suppression list. + + Call this when a ticket is resolved, closed, or re-opened so that + subsequent breaches (if the ticket re-opens) trigger fresh alerts. + Callers are responsible for committing after calling this function. + """ + from app.models import SystemSetting + raw = SystemSetting.get('sla_notified_tickets', '') + current = set(int(x) for x in raw.split(',') if x.strip().isdigit()) + current.discard(ticket_id) + SystemSetting.set( + 'sla_notified_tickets', + ','.join(str(i) for i in sorted(current)), + ) diff --git a/app/templates/admin/canned_response_edit.html b/app/templates/admin/canned_response_edit.html new file mode 100644 index 0000000..a918f0e --- /dev/null +++ b/app/templates/admin/canned_response_edit.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if item else 'New' }} Quick Reply{% endblock %} +{% block page_title %}{{ 'Edit' if item else 'New' }} Quick Reply{% endblock %} + +{% block content %} +
+
+
+
+ + {{ 'Edit Quick Reply' if item else 'Create Quick Reply' }} +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ Use **bold**, _italic_, `code`, and - lists. This text will be inserted + directly into the comment box when selected. +
+
+ + {% if item %} +
+ + +
+ {% endif %} + +
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/canned_responses.html b/app/templates/admin/canned_responses.html new file mode 100644 index 0000000..d225dd1 --- /dev/null +++ b/app/templates/admin/canned_responses.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Quick Replies{% endblock %} +{% block page_title %}Quick Replies{% endblock %} + +{% block content %} +
+

+ Pre-written responses that IT staff can insert into ticket comments with one click. +

+ + New Quick Reply + +
+ +{% if items %} +{% set ns = namespace(last_cat='') %} +{% for item in items %} + {% if item.category and item.category != ns.last_cat %} +
{{ item.category }}
+ {% set ns.last_cat = item.category %} + {% elif not item.category and ns.last_cat %} +
Uncategorised
+ {% set ns.last_cat = '' %} + {% endif %} + +
+
+
+
+ {{ item.title }} + {% if not item.is_active %} + Inactive + {% endif %} +
+
{{ item.body }}
+
+ Added by {{ item.creator.full_name }} · {{ item.created_at.strftime('%b %d, %Y') }} +
+
+
+ +
+ + +
+
+
+
+{% endfor %} +{% else %} +
+
+ + No quick replies yet. Create one to speed up IT responses. +
+
+{% endif %} +{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index 28afdc8..9d46883 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -73,11 +73,51 @@
- + +
+ + +
+ + + +
- +
+ + +
+
@@ -90,4 +130,84 @@ + +{% block scripts %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index adef2b6..6282702 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -335,6 +335,9 @@
  • Manage KB
  • +
  • + Quick Replies +
  • {% if current_user.is_admin %}
  • Users diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 4fb1842..d18ca6b 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -179,6 +179,33 @@ accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/> {% if current_user.is_it_staff %} + + {% if canned_responses %} +
    + +
    + {% endif %}