04/06 adding some additional functions
This commit is contained in:
@@ -144,14 +144,59 @@ def create_app(config_name=None):
|
|||||||
_seed_admin(app)
|
_seed_admin(app)
|
||||||
_seed_settings()
|
_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
|
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():
|
def _seed_settings():
|
||||||
"""Ensure all required system settings exist with safe defaults."""
|
"""Ensure all required system settings exist with safe defaults."""
|
||||||
from app.models import SystemSetting
|
from app.models import SystemSetting
|
||||||
defaults = [
|
defaults = [
|
||||||
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
|
('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_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'),
|
('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'),
|
('company_name', '', 'Company name shown on the login and register pages'),
|
||||||
|
|||||||
@@ -360,3 +360,62 @@ class SystemSetting(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SystemSetting {self.key}={self.value}>'
|
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
@@ -11,7 +11,7 @@ from werkzeug.utils import secure_filename
|
|||||||
import bleach
|
import bleach
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
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.log_service import log_action
|
||||||
from app.services.validation_service import validate_password, validate_file
|
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():
|
def _roles():
|
||||||
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
|
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
|
||||||
|
|
||||||
|
|||||||
+197
-1
@@ -9,12 +9,13 @@ from werkzeug.utils import secure_filename
|
|||||||
from app import db
|
from app import db
|
||||||
from app.models import (Ticket, Comment, Attachment, Notification,
|
from app.models import (Ticket, Comment, Attachment, Notification,
|
||||||
TicketStatus, TicketPriority, TicketCategory,
|
TicketStatus, TicketPriority, TicketCategory,
|
||||||
User, UserRole, KnowledgeBase)
|
User, UserRole, KnowledgeBase, TicketLink, CannedResponse)
|
||||||
from app.services.notification_service import (
|
from app.services.notification_service import (
|
||||||
notify_new_ticket, notify_status_change,
|
notify_new_ticket, notify_status_change,
|
||||||
notify_comment_added, notify_assignment,
|
notify_comment_added, notify_assignment,
|
||||||
)
|
)
|
||||||
from app.services.log_service import log_action, log_ticket_history
|
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
|
from app.services.validation_service import validate_file, render_comment_body
|
||||||
|
|
||||||
tickets_bp = Blueprint('tickets', __name__)
|
tickets_bp = Blueprint('tickets', __name__)
|
||||||
@@ -162,6 +163,7 @@ def create_ticket():
|
|||||||
asset_tag = asset_tag,
|
asset_tag = asset_tag,
|
||||||
created_by_id = current_user.id,
|
created_by_id = current_user.id,
|
||||||
status = TicketStatus.OPEN,
|
status = TicketStatus.OPEN,
|
||||||
|
due_date = _sla_due_date(priority), # auto-set SLA deadline
|
||||||
)
|
)
|
||||||
ticket.ticket_number = ticket.generate_ticket_number()
|
ticket.ticket_number = ticket.generate_ticket_number()
|
||||||
db.session.add(ticket)
|
db.session.add(ticket)
|
||||||
@@ -250,6 +252,7 @@ def create_ticket_behalf():
|
|||||||
created_by_id = employee.id, # ticket belongs to the employee
|
created_by_id = employee.id, # ticket belongs to the employee
|
||||||
created_by_staff_id = current_user.id, # IT staff who filed it
|
created_by_staff_id = current_user.id, # IT staff who filed it
|
||||||
status = TicketStatus.OPEN,
|
status = TicketStatus.OPEN,
|
||||||
|
due_date = _sla_due_date(priority), # auto-set SLA deadline
|
||||||
)
|
)
|
||||||
ticket.ticket_number = ticket.generate_ticket_number()
|
ticket.ticket_number = ticket.generate_ticket_number()
|
||||||
db.session.add(ticket)
|
db.session.add(ticket)
|
||||||
@@ -421,10 +424,28 @@ def ticket_detail(ticket_id):
|
|||||||
|
|
||||||
history = ticket.history.order_by('changed_at').all()
|
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',
|
return render_template('tickets/detail.html',
|
||||||
ticket=ticket, comments=comments,
|
ticket=ticket, comments=comments,
|
||||||
it_staff=it_staff, history=history,
|
it_staff=it_staff, history=history,
|
||||||
statuses=_statuses(), priorities=_priorities(),
|
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}')
|
changes.append(f'status: {old_status} → {new_status}')
|
||||||
if new_status == TicketStatus.RESOLVED:
|
if new_status == TicketStatus.RESOLVED:
|
||||||
ticket.resolved_at = datetime.utcnow()
|
ticket.resolved_at = datetime.utcnow()
|
||||||
|
clear_sla_notification(ticket.id) # remove from breach suppression list
|
||||||
elif new_status == TicketStatus.CLOSED:
|
elif new_status == TicketStatus.CLOSED:
|
||||||
ticket.closed_at = datetime.utcnow()
|
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:
|
if new_priority != old_priority:
|
||||||
ticket.priority = new_priority
|
ticket.priority = new_priority
|
||||||
@@ -652,8 +677,179 @@ def kb_article(article_id):
|
|||||||
return render_template('tickets/kb_article.html', article=article)
|
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 ─────────────────────────────────────────────────────────────────
|
# ─── 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():
|
def _statuses():
|
||||||
return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS,
|
return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS,
|
||||||
TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED]
|
TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED]
|
||||||
|
|||||||
@@ -335,12 +335,18 @@ def notify_comment_added(comment):
|
|||||||
|
|
||||||
|
|
||||||
def notify_assignment(ticket, assigned_by):
|
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:
|
if not ticket.assigned_to_id:
|
||||||
return
|
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}"
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
# ── Notify the IT staff assignee ──────────────────────────────────────────
|
||||||
create_notification(
|
create_notification(
|
||||||
user_id = ticket.assigned_to_id,
|
user_id = ticket.assigned_to_id,
|
||||||
notif_type= NotificationType.TICKET_ASSIGNED,
|
notif_type= NotificationType.TICKET_ASSIGNED,
|
||||||
@@ -349,7 +355,6 @@ def notify_assignment(ticket, assigned_by):
|
|||||||
ticket_id = ticket.id,
|
ticket_id = ticket.id,
|
||||||
link = f'/tickets/{ticket.id}',
|
link = f'/tickets/{ticket.id}',
|
||||||
)
|
)
|
||||||
db.session.commit()
|
|
||||||
if ticket.assignee and ticket.assignee.email_notif:
|
if ticket.assignee and ticket.assignee.email_notif:
|
||||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
ticket_number = ticket.ticket_number,
|
ticket_number = ticket.ticket_number,
|
||||||
@@ -362,4 +367,41 @@ def notify_assignment(ticket, assigned_by):
|
|||||||
[ticket.assignee.email],
|
[ticket.assignee.email],
|
||||||
html,
|
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}'
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 & Action Ticket
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;
|
||||||
|
color:#999;font-size:12px;">
|
||||||
|
IT Helpdesk System • 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -73,11 +73,51 @@
|
|||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">New Password</label>
|
<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>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Confirm Password</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,4 +130,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
@@ -335,6 +335,9 @@
|
|||||||
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
|
<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
|
<i class="bi bi-journal-text"></i> Manage KB
|
||||||
</a></li>
|
</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 %}
|
{% if current_user.is_admin %}
|
||||||
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
||||||
<i class="bi bi-people"></i> Users
|
<i class="bi bi-people"></i> Users
|
||||||
|
|||||||
@@ -179,6 +179,33 @@
|
|||||||
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
|
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_it_staff %}
|
{% 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">
|
<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);"/>
|
<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;">
|
<label for="is_internal" style="font-size:13px;color:var(--warning);margin:0;cursor:pointer;">
|
||||||
@@ -196,6 +223,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<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 TICKET_ID = {{ ticket.id }};
|
||||||
const CURRENT_UID = {{ current_user.id }};
|
const CURRENT_UID = {{ current_user.id }};
|
||||||
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
|
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
|
||||||
@@ -587,6 +628,69 @@ function buildCommentEl(c) {
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Ticket Info -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
|
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
|
||||||
@@ -647,4 +751,53 @@ function buildCommentEl(c) {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 & Notify IT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -62,6 +62,14 @@ class Config:
|
|||||||
# To switch to Redis when it becomes available: set RATELIMIT_STORAGE_URI=redis://...
|
# To switch to Redis when it becomes available: set RATELIMIT_STORAGE_URI=redis://...
|
||||||
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://')
|
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
|
||||||
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
|
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
|
||||||
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')
|
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')
|
||||||
|
|||||||
Reference in New Issue
Block a user