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_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'),
|
||||
|
||||
+60
-1
@@ -359,4 +359,63 @@ class SystemSetting(db.Model):
|
||||
return row
|
||||
|
||||
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
|
||||
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
@@ -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]
|
||||
|
||||
@@ -335,12 +335,18 @@ def notify_comment_added(comment):
|
||||
|
||||
|
||||
def notify_assignment(ticket, assigned_by):
|
||||
"""Notify newly assigned IT staff member."""
|
||||
"""Notify newly assigned IT staff member AND the ticket creator (employee).
|
||||
|
||||
The employee who submitted the ticket (or on whose behalf it was filed)
|
||||
receives a confirmation that their issue has been picked up, giving
|
||||
them visibility without requiring them to poll the ticket page.
|
||||
"""
|
||||
if not ticket.assigned_to_id:
|
||||
return
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||
|
||||
# ── Notify the IT staff assignee ──────────────────────────────────────────
|
||||
create_notification(
|
||||
user_id = ticket.assigned_to_id,
|
||||
notif_type= NotificationType.TICKET_ASSIGNED,
|
||||
@@ -349,7 +355,6 @@ def notify_assignment(ticket, assigned_by):
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}',
|
||||
)
|
||||
db.session.commit()
|
||||
if ticket.assignee and ticket.assignee.email_notif:
|
||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||
ticket_number = ticket.ticket_number,
|
||||
@@ -362,4 +367,41 @@ def notify_assignment(ticket, assigned_by):
|
||||
[ticket.assignee.email],
|
||||
html,
|
||||
)
|
||||
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
|
||||
|
||||
# ── 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="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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Notify IT
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user