04/06 adding some additional functions

This commit is contained in:
2026-04-06 18:02:16 -04:00
parent bc236a3d19
commit 299bf2ca05
12 changed files with 1142 additions and 9 deletions
+45
View File
@@ -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'),
+59
View File
@@ -360,3 +360,62 @@ class SystemSetting(db.Model):
def __repr__(self):
return f'<SystemSetting {self.key}={self.value}>'
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'<TicketLink {self.ticket_id}{self.linked_ticket_id} ({self.link_type})>'
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'<CannedResponse {self.title}>'
+80 -1
View File
@@ -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/<int:item_id>/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/<int:item_id>/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]
+197 -1
View File
@@ -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/<int:ticket_id>/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/<int:ticket_id>/unlink/<int:link_id>', 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/<int:ticket_id>/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]
+45 -3
View File
@@ -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', '')
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}')
# ── 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}'
)
+294
View File
@@ -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 = """
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;
overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
<div style="background:#7f1d1d;padding:24px 32px;">
<h1 style="color:#fca5a5;margin:0;font-size:22px;"> SLA Breach Action Required</h1>
</div>
<div style="padding:32px;">
<p style="color:#555;margin-top:0;">
The following ticket has exceeded its SLA response target and requires immediate attention.
</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr><td style="padding:8px;color:#888;width:140px;">Ticket #</td>
<td style="padding:8px;font-weight:bold;">{{ ticket_number }}</td></tr>
<tr style="background:#f9f9f9;">
<td style="padding:8px;color:#888;">Title</td>
<td style="padding:8px;">{{ title }}</td></tr>
<tr><td style="padding:8px;color:#888;">Priority</td>
<td style="padding:8px;">
<span style="background:{{ priority_color }};color:#fff;
padding:2px 8px;border-radius:4px;">{{ priority }}</span>
</td></tr>
<tr style="background:#f9f9f9;">
<td style="padding:8px;color:#888;">Status</td>
<td style="padding:8px;">{{ status }}</td></tr>
<tr><td style="padding:8px;color:#888;">Assigned To</td>
<td style="padding:8px;">{{ assigned_to }}</td></tr>
<tr style="background:#fef2f2;">
<td style="padding:8px;color:#888;">Due Date</td>
<td style="padding:8px;color:#dc2626;font-weight:bold;">{{ due_date }} (overdue)</td></tr>
</table>
<a href="{{ ticket_url }}"
style="display:inline-block;background:#dc2626;color:#fff;
padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">
View &amp; Action Ticket
</a>
</div>
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;
color:#999;font-size:12px;">
IT Helpdesk System &bull; This is an automated SLA alert.
</div>
</div>
</body></html>
"""
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)),
)
@@ -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 %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="bi bi-chat-square-text me-2"></i>
{{ 'Edit Quick Reply' if item else 'Create Quick Reply' }}
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Title *
<span class="text-muted fw-normal" style="font-size:12px;">
— shown in the dropdown menu on ticket comments
</span>
</label>
<input type="text" class="form-control" name="title" required maxlength="120"
value="{{ item.title if item else '' }}"
placeholder="e.g. Please restart and confirm"/>
</div>
<div class="mb-3">
<label class="form-label">Category
<span class="text-muted fw-normal" style="font-size:12px;">— optional grouping label</span>
</label>
<input type="text" class="form-control" name="category" maxlength="50"
value="{{ item.category if item and item.category else '' }}"
placeholder="e.g. Hardware, Software, Escalation"/>
</div>
<div class="mb-3">
<label class="form-label">Response Body *
<span class="text-muted fw-normal" style="font-size:12px;">— Markdown supported</span>
</label>
<textarea class="form-control" name="body" rows="8" required
placeholder="Please restart your device and let us know if the issue persists.">{{ item.body if item else '' }}</textarea>
<div class="form-text">
Use **bold**, _italic_, `code`, and - lists. This text will be inserted
directly into the comment box when selected.
</div>
</div>
{% if item %}
<div class="mb-4 d-flex align-items-center gap-2">
<input type="checkbox" id="is_active" name="is_active"
{% if item.is_active %}checked{% endif %}
style="accent-color:var(--accent);width:16px;height:16px;"/>
<label for="is_active" style="font-size:14px;margin:0;cursor:pointer;">
Active (visible to IT staff in the quick replies dropdown)
</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>{{ 'Save Changes' if item else 'Create Quick Reply' }}
</button>
<a href="{{ url_for('admin.canned_responses') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Quick Replies{% endblock %}
{% block page_title %}Quick Replies{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<p style="color:var(--muted);font-size:13px;margin:0;">
Pre-written responses that IT staff can insert into ticket comments with one click.
</p>
<a href="{{ url_for('admin.canned_response_new') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>New Quick Reply
</a>
</div>
{% if items %}
{% set ns = namespace(last_cat='') %}
{% for item in items %}
{% if item.category and item.category != ns.last_cat %}
<h6 style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;
color:var(--muted);margin:20px 0 8px;">{{ item.category }}</h6>
{% set ns.last_cat = item.category %}
{% elif not item.category and ns.last_cat %}
<h6 style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;
color:var(--muted);margin:20px 0 8px;">Uncategorised</h6>
{% set ns.last_cat = '' %}
{% endif %}
<div class="card mb-2 {% if not item.is_active %}opacity-50{% endif %}">
<div class="card-body py-3 px-4 d-flex align-items-start gap-3">
<div style="flex:1;min-width:0;">
<div class="d-flex align-items-center gap-2 mb-1">
<span style="font-size:14px;font-weight:600;">{{ item.title }}</span>
{% if not item.is_active %}
<span class="badge bg-secondary" style="font-size:10px;">Inactive</span>
{% endif %}
</div>
<div style="font-size:13px;color:var(--muted);white-space:pre-wrap;max-height:60px;overflow:hidden;text-overflow:ellipsis;">{{ item.body }}</div>
<div style="font-size:11px;color:var(--muted2);margin-top:4px;">
Added by {{ item.creator.full_name }} · {{ item.created_at.strftime('%b %d, %Y') }}
</div>
</div>
<div class="d-flex gap-2 flex-shrink-0">
<a href="{{ url_for('admin.canned_response_edit', item_id=item.id) }}"
class="btn btn-secondary btn-sm"><i class="bi bi-pencil"></i></a>
<form method="POST"
action="{{ url_for('admin.canned_response_delete', item_id=item.id) }}"
onsubmit="return confirm('Delete this quick reply?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-chat-square-text" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No quick replies yet. Create one to speed up IT responses.
</div>
</div>
{% endif %}
{% endblock %}
+122 -2
View File
@@ -73,11 +73,51 @@
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="new_password" placeholder="Min. 8 characters"/>
<!-- Show/hide toggle wrapper -->
<div style="position:relative;">
<input type="password" class="form-control" name="new_password"
id="prof-pw" placeholder="Min. 8 characters"
autocomplete="new-password"
oninput="profPwInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw-toggle"
onclick="profToggle('prof-pw','prof-pw-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<i class="bi bi-eye"></i>
</button>
</div>
<!-- Strength bar (hidden until typing starts) -->
<div id="prof-strength-wrap" style="display:none;margin-top:6px;">
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:5px;">
<div id="prof-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
</div>
<div id="prof-strength-label" style="font-size:11px;font-weight:600;font-family:'Space Mono',monospace;display:flex;align-items:center;gap:5px;"></div>
</div>
<!-- Rule checklist -->
<div id="prof-rules" style="display:none;margin-top:7px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;">
<div class="prof-rule" id="prof-rule-len" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> 8+ characters</div>
<div class="prof-rule" id="prof-rule-upper" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Uppercase letter</div>
<div class="prof-rule" id="prof-rule-digit" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Number</div>
<div class="prof-rule" id="prof-rule-special"style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Special character</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" name="confirm_password" placeholder="Repeat password"/>
<div style="position:relative;">
<input type="password" class="form-control" name="confirm_password"
id="prof-pw2" placeholder="Repeat password"
autocomplete="new-password"
oninput="profConfirmInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw2-toggle"
onclick="profToggle('prof-pw2','prof-pw2-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<i class="bi bi-eye"></i>
</button>
</div>
<div id="prof-match-msg" style="font-size:11.5px;margin-top:6px;font-family:'Space Mono',monospace;font-weight:600;min-height:18px;"></div>
</div>
</div>
@@ -90,4 +130,84 @@
</div>
</div>
</div>
{% block scripts %}
<script>
// ── Profile page password strength — mirrors register.html rules exactly ──────
const PROF_RULES = {
len: pw => pw.length >= 8,
upper: pw => /[A-Z]/.test(pw),
digit: pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\|`~]/.test(pw),
};
const PROF_LEVELS = [
{ label:'Very Weak', color:'#ef4444', pct:12 },
{ label:'Weak', color:'#f97316', pct:30 },
{ label:'Fair', color:'#eab308', pct:52 },
{ label:'Good', color:'#22c55e', pct:76 },
{ label:'Strong', color:'#059669', pct:100 },
];
function profScore(pw) {
return [PROF_RULES.len,PROF_RULES.upper,PROF_RULES.digit,PROF_RULES.special]
.filter(r => r(pw)).length + (pw.length >= 16 ? 1 : 0);
}
function profSetRule(id, met) {
const el = document.getElementById('prof-rule-' + id);
if (!el) return;
el.style.color = met ? 'var(--success)' : 'var(--muted)';
el.querySelector('.prof-ri').innerHTML = met
? '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i>'
: '<i class="bi bi-circle"></i>';
}
function profPwInput() {
const pw = document.getElementById('prof-pw').value;
const wrap = document.getElementById('prof-strength-wrap');
const rules = document.getElementById('prof-rules');
const bar = document.getElementById('prof-strength-bar');
const lbl = document.getElementById('prof-strength-label');
if (!pw) {
wrap.style.display = 'none'; rules.style.display = 'none';
document.getElementById('prof-pw').classList.remove('is-valid','is-invalid');
profConfirmInput(); return;
}
wrap.style.display = 'block'; rules.style.display = 'grid';
profSetRule('len', PROF_RULES.len(pw));
profSetRule('upper', PROF_RULES.upper(pw));
profSetRule('digit', PROF_RULES.digit(pw));
profSetRule('special', PROF_RULES.special(pw));
const score = profScore(pw);
const lvl = PROF_LEVELS[Math.max(0, score - 1)] || PROF_LEVELS[0];
bar.style.width = lvl.pct + '%';
bar.style.background = lvl.color;
lbl.style.color = lvl.color;
const icon = score >= 4 ? 'shield-fill' : score >= 2 ? 'shield-half' : 'shield';
lbl.innerHTML = `<i class="bi bi-${icon}"></i> ${lvl.label}`;
const allMet = Object.values(PROF_RULES).every(r => r(pw));
document.getElementById('prof-pw').classList.toggle('is-valid', allMet);
document.getElementById('prof-pw').classList.toggle('is-invalid', !allMet);
profConfirmInput();
}
function profConfirmInput() {
const pw = document.getElementById('prof-pw').value;
const pw2 = document.getElementById('prof-pw2').value;
const msg = document.getElementById('prof-match-msg');
const el2 = document.getElementById('prof-pw2');
if (!pw2) { msg.innerHTML=''; el2.classList.remove('is-valid','is-invalid'); return; }
const ok = pw === pw2;
msg.innerHTML = ok
? '<span style="color:var(--success);"><i class="bi bi-check-circle-fill me-1"></i>Passwords match</span>'
: '<span style="color:var(--danger);"><i class="bi bi-x-circle-fill me-1"></i>Passwords do not match</span>';
el2.classList.toggle('is-valid', ok);
el2.classList.toggle('is-invalid', !ok);
}
function profToggle(inputId, btnId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
const isPassword = inp.type === 'password';
inp.type = isPassword ? 'text' : 'password';
btn.querySelector('i').className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
}
</script>
{% endblock %}
{% endblock %}
+3
View File
@@ -335,6 +335,9 @@
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
<i class="bi bi-journal-text"></i> Manage KB
</a></li>
<li><a href="{{ url_for('admin.canned_responses') }}" class="{{ 'active' if 'canned_response' in request.endpoint }}">
<i class="bi bi-chat-square-text"></i> Quick Replies
</a></li>
{% if current_user.is_admin %}
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
<i class="bi bi-people"></i> Users
+153
View File
@@ -179,6 +179,33 @@
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div>
{% if current_user.is_it_staff %}
<!-- Canned responses picker -->
{% if canned_responses %}
<div class="mb-3">
<div class="dropdown">
<button type="button" class="btn btn-secondary btn-sm dropdown-toggle"
id="canned-responses-btn" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-square-text me-1"></i>Quick Replies
</button>
<ul class="dropdown-menu" style="max-height:260px;overflow-y:auto;min-width:300px;">
{% set ns = namespace(last_cat='') %}
{% for r in canned_responses %}
{% if r.category and r.category != ns.last_cat %}
{% if ns.last_cat %}<li><hr class="dropdown-divider"/></li>{% endif %}
<li><span class="dropdown-header" style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;">{{ r.category }}</span></li>
{% set ns.last_cat = r.category %}
{% endif %}
<li>
<button type="button" class="dropdown-item" style="font-size:13px;white-space:normal;"
onclick="insertCannedResponse({{ r.id }})">
{{ r.title }}
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="mb-3 d-flex align-items-center gap-2">
<input type="checkbox" id="is_internal" name="is_internal" style="accent-color:var(--warning);"/>
<label for="is_internal" style="font-size:13px;color:var(--warning);margin:0;cursor:pointer;">
@@ -196,6 +223,20 @@
{% endif %}
<script>
const CANNED_RESPONSES = {
{% for r in canned_responses %}
{{ r.id }}: {{ r.body | tojson }},
{% endfor %}
};
function insertCannedResponse(id) {
const body = CANNED_RESPONSES[id];
if (!body) return;
const ta = document.getElementById('comment-body');
const cur = ta.value;
ta.value = cur ? cur + '\n\n' + body : body;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}
const TICKET_ID = {{ ticket.id }};
const CURRENT_UID = {{ current_user.id }};
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
@@ -587,6 +628,69 @@ function buildCommentEl(c) {
</div>
{% endif %}
<!-- Linked Tickets -->
{% if current_user.is_it_staff %}
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-link-45deg me-2"></i>Linked Tickets</span>
<button class="btn btn-secondary btn-sm" type="button"
data-bs-toggle="collapse" data-bs-target="#link-form-collapse"
style="font-size:11px;">
<i class="bi bi-plus me-1"></i>Link
</button>
</div>
<div class="card-body" style="font-size:13px;">
{% if linked_tickets %}
{% for lnk, other in linked_tickets %}
<div class="d-flex align-items-center justify-content-between mb-2"
style="padding:6px 8px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);">
<div style="min-width:0;">
<a href="{{ url_for('tickets.ticket_detail', ticket_id=other.id) }}"
class="mono" style="font-size:11px;color:var(--accent3);">{{ other.ticket_number }}</a>
<span style="font-size:10px;background:var(--border);color:var(--muted);padding:1px 6px;border-radius:4px;margin-left:4px;">{{ lnk.link_type.replace('_',' ') }}</span>
<div style="font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ other.title }}</div>
</div>
<form method="POST"
action="{{ url_for('tickets.unlink_ticket', ticket_id=ticket.id, link_id=lnk.id) }}"
onsubmit="return confirm('Remove this link?')" style="margin:0;flex-shrink:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm" title="Remove link"
style="background:none;border:none;color:var(--muted);padding:2px 6px;">
<i class="bi bi-x-lg"></i>
</button>
</form>
</div>
{% endfor %}
{% else %}
<p style="color:var(--muted);font-size:12px;margin:0;">No linked tickets yet.</p>
{% endif %}
<!-- Link form (collapsed by default) -->
<div class="collapse mt-3" id="link-form-collapse">
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Ticket ID or Number</label>
<input type="number" class="form-control form-control-sm"
name="linked_ticket_id" placeholder="e.g. 42" required min="1"/>
</div>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Relationship</label>
<select class="form-select form-select-sm" name="link_type">
<option value="related">Related</option>
<option value="duplicate">Duplicate</option>
<option value="follow_up">Follow-up</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-link-45deg me-1"></i>Create Link
</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Ticket Info -->
<div class="card mb-3">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
@@ -647,4 +751,53 @@ function buildCommentEl(c) {
{% endif %}
</div>
</div>
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
{% if ticket.status in ('resolved', 'closed') and
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}
<div class="card mt-4" style="border-color:var(--warning);max-width:760px;">
<div class="card-body d-flex align-items-center gap-3" style="padding:16px 20px;">
<i class="bi bi-arrow-repeat" style="font-size:22px;color:var(--warning);flex-shrink:0;"></i>
<div style="flex:1;">
<div style="font-weight:600;font-size:14px;">Has this issue returned?</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
Re-open this ticket to notify IT staff and resume tracking.
</div>
</div>
<button type="button" class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal" data-bs-target="#reopen-modal">
<i class="bi bi-arrow-repeat me-1"></i>Re-open Ticket
</button>
</div>
</div>
<!-- Re-open modal -->
<div class="modal fade" id="reopen-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Re-open Ticket</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('tickets.reopen_ticket', ticket_id=ticket.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<p style="font-size:13px;color:var(--muted);">
Please describe why the issue has returned. This will be posted as a comment
and IT staff will be notified immediately.
</p>
<textarea class="form-control" name="reopen_reason" rows="4" required
placeholder="e.g. The problem came back after the weekend restart. The error message is now different: …"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Re-open &amp; Notify IT
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+8
View File
@@ -62,6 +62,14 @@ class Config:
# To switch to Redis when it becomes available: set RATELIMIT_STORAGE_URI=redis://...
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://')
# SLA — hours until a ticket is considered overdue, by priority.
# Override via environment variables if needed (e.g. SLA_CRITICAL_HOURS=2).
# These are also seeded into SystemSetting so admins can tune them in-app.
SLA_CRITICAL_HOURS = int(os.environ.get('SLA_CRITICAL_HOURS', 4))
SLA_HIGH_HOURS = int(os.environ.get('SLA_HIGH_HOURS', 8))
SLA_MEDIUM_HOURS = int(os.environ.get('SLA_MEDIUM_HOURS', 48))
SLA_LOW_HOURS = int(os.environ.get('SLA_LOW_HOURS', 120))
# Admin
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')