04/06 adding some additional functions

This commit is contained in:
2026-04-06 18:02:16 -04:00
parent bc236a3d19
commit 299bf2ca05
12 changed files with 1142 additions and 9 deletions
+80 -1
View File
@@ -11,7 +11,7 @@ from werkzeug.utils import secure_filename
import bleach
from app import db, limiter
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus)
KBAttachment, UserRole, TicketStatus, CannedResponse)
from app.services.log_service import log_action
from app.services.validation_service import validate_password, validate_file
@@ -851,6 +851,85 @@ def export_tickets():
)
# ─── Canned Responses Management ─────────────────────────────────────────────
@admin_bp.route('/canned-responses')
@login_required
@it_required
def canned_responses():
"""List all canned responses."""
items = CannedResponse.query.order_by(
CannedResponse.category, CannedResponse.title
).all()
categories = sorted({r.category for r in items if r.category})
return render_template('admin/canned_responses.html',
items=items, categories=categories)
@admin_bp.route('/canned-responses/new', methods=['GET', 'POST'])
@login_required
@it_required
def canned_response_new():
if request.method == 'POST':
title = request.form.get('title', '').strip()
body = request.form.get('body', '').strip()
category = request.form.get('category', '').strip()
if not title or not body:
flash('Title and body are required.', 'danger')
return render_template('admin/canned_response_edit.html', item=None)
item = CannedResponse(
title = title,
body = body,
category = category or None,
created_by = current_user.id,
)
db.session.add(item)
db.session.flush()
log_action(current_user.id, 'canned_response_create', 'canned_response', item.id,
f'title={title}')
db.session.commit()
logger.info(f'[CANNED RESPONSE CREATE] id={item.id} by user_id={current_user.id}')
flash('Canned response created.', 'success')
return redirect(url_for('admin.canned_responses'))
return render_template('admin/canned_response_edit.html', item=None)
@admin_bp.route('/canned-responses/<int:item_id>/edit', methods=['GET', 'POST'])
@login_required
@it_required
def canned_response_edit(item_id):
item = db.session.get(CannedResponse, item_id) or abort(404)
if request.method == 'POST':
item.title = request.form.get('title', item.title).strip()
item.body = request.form.get('body', item.body).strip()
item.category = request.form.get('category', '').strip() or None
item.is_active = bool(request.form.get('is_active'))
if not item.title or not item.body:
flash('Title and body are required.', 'danger')
return render_template('admin/canned_response_edit.html', item=item)
log_action(current_user.id, 'canned_response_edit', 'canned_response', item.id,
f'title={item.title}')
db.session.commit()
logger.info(f'[CANNED RESPONSE EDIT] id={item.id} by user_id={current_user.id}')
flash('Canned response updated.', 'success')
return redirect(url_for('admin.canned_responses'))
return render_template('admin/canned_response_edit.html', item=item)
@admin_bp.route('/canned-responses/<int:item_id>/delete', methods=['POST'])
@login_required
@it_required
def canned_response_delete(item_id):
item = db.session.get(CannedResponse, item_id) or abort(404)
log_action(current_user.id, 'canned_response_delete', 'canned_response', item.id,
f'title={item.title}')
logger.info(f'[CANNED RESPONSE DELETE] id={item.id} by user_id={current_user.id}')
db.session.delete(item)
db.session.commit()
flash('Canned response deleted.', 'success')
return redirect(url_for('admin.canned_responses'))
def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
+197 -1
View File
@@ -9,12 +9,13 @@ from werkzeug.utils import secure_filename
from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase)
User, UserRole, KnowledgeBase, TicketLink, CannedResponse)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
)
from app.services.log_service import log_action, log_ticket_history
from app.services.sla_service import clear_sla_notification
from app.services.validation_service import validate_file, render_comment_body
tickets_bp = Blueprint('tickets', __name__)
@@ -162,6 +163,7 @@ def create_ticket():
asset_tag = asset_tag,
created_by_id = current_user.id,
status = TicketStatus.OPEN,
due_date = _sla_due_date(priority), # auto-set SLA deadline
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
@@ -250,6 +252,7 @@ def create_ticket_behalf():
created_by_id = employee.id, # ticket belongs to the employee
created_by_staff_id = current_user.id, # IT staff who filed it
status = TicketStatus.OPEN,
due_date = _sla_due_date(priority), # auto-set SLA deadline
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
@@ -421,10 +424,28 @@ def ticket_detail(ticket_id):
history = ticket.history.order_by('changed_at').all()
# Collect all links for this ticket from both directions
links_src = ticket.links_as_source.all()
links_tgt = ticket.links_as_target.all()
# Build a unified list of (link_obj, other_ticket) tuples for the template
linked_tickets = (
[(lnk, lnk.linked_ticket) for lnk in links_src] +
[(lnk, lnk.ticket) for lnk in links_tgt]
)
# Canned responses for IT staff comment form
canned_responses = []
if current_user.is_it_staff:
canned_responses = CannedResponse.query.filter_by(is_active=True).order_by(
CannedResponse.category, CannedResponse.title
).all()
return render_template('tickets/detail.html',
ticket=ticket, comments=comments,
it_staff=it_staff, history=history,
statuses=_statuses(), priorities=_priorities(),
linked_tickets=linked_tickets,
canned_responses=canned_responses,
)
@@ -456,8 +477,12 @@ def update_ticket(ticket_id):
changes.append(f'status: {old_status}{new_status}')
if new_status == TicketStatus.RESOLVED:
ticket.resolved_at = datetime.utcnow()
clear_sla_notification(ticket.id) # remove from breach suppression list
elif new_status == TicketStatus.CLOSED:
ticket.closed_at = datetime.utcnow()
clear_sla_notification(ticket.id) # remove from breach suppression list
elif new_status == TicketStatus.OPEN:
clear_sla_notification(ticket.id) # re-opened — allow fresh breach alerts
if new_priority != old_priority:
ticket.priority = new_priority
@@ -652,8 +677,179 @@ def kb_article(article_id):
return render_template('tickets/kb_article.html', article=article)
# ─── Ticket Link / Unlink ────────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>/link', methods=['POST'])
@login_required
def link_ticket(ticket_id):
"""Create a bidirectional link between two tickets (IT staff only)."""
if not current_user.is_it_staff:
abort(403)
ticket = db.session.get(Ticket, ticket_id) or abort(404)
other_id = request.form.get('linked_ticket_id', type=int)
link_type = request.form.get('link_type', 'related')
if not other_id:
flash('Please specify a ticket to link.', 'danger')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
if other_id == ticket_id:
flash('A ticket cannot be linked to itself.', 'danger')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
other = db.session.get(Ticket, other_id)
if not other:
flash(f'Ticket #{other_id} not found.', 'danger')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# Enforce canonical ordering (smaller id first) for the uniqueness constraint
a, b = sorted([ticket_id, other_id])
existing = TicketLink.query.filter_by(ticket_id=a, linked_ticket_id=b).first()
if existing:
flash(f'These tickets are already linked.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
if link_type not in ('related', 'duplicate', 'follow_up'):
link_type = 'related'
link = TicketLink(
ticket_id = a,
linked_ticket_id = b,
link_type = link_type,
created_by = current_user.id,
)
db.session.add(link)
log_action(current_user.id, 'ticket_link_create', 'ticket', ticket_id,
f'linked_to={other_id} type={link_type}')
db.session.commit()
logger.info(f'[TICKET LINK] ticket_id={a} linked_ticket_id={b} '
f'type={link_type} by user_id={current_user.id}')
flash(f'Linked to {other.ticket_number} ({link_type.replace("_", " ")}).', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
@tickets_bp.route('/tickets/<int:ticket_id>/unlink/<int:link_id>', methods=['POST'])
@login_required
def unlink_ticket(ticket_id, link_id):
"""Remove a ticket link (IT staff only)."""
if not current_user.is_it_staff:
abort(403)
link = db.session.get(TicketLink, link_id) or abort(404)
if link.ticket_id != ticket_id and link.linked_ticket_id != ticket_id:
abort(403)
log_action(current_user.id, 'ticket_link_delete', 'ticket', ticket_id,
f'link_id={link_id} removed')
logger.info(f'[TICKET UNLINK] link_id={link_id} ticket_id={ticket_id} '
f'by user_id={current_user.id}')
db.session.delete(link)
db.session.commit()
flash('Link removed.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# ─── Ticket Re-open ───────────────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>/reopen', methods=['POST'])
@login_required
def reopen_ticket(ticket_id):
"""Allow the ticket creator to re-open a resolved or closed ticket.
A re-open creates a comment with the employee's explanation, resets the
ticket status to Open, and notifies all IT staff so the ticket resurfaces
in the queue without being lost.
"""
ticket = db.session.get(Ticket, ticket_id) or abort(404)
# Only the original creator (or IT staff) can re-open
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
if ticket.status not in (TicketStatus.RESOLVED, TicketStatus.CLOSED):
flash('Only resolved or closed tickets can be re-opened.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
reason = request.form.get('reopen_reason', '').strip()
if not reason:
flash('Please describe why the issue has returned.', 'danger')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
old_status = ticket.status
ticket.status = TicketStatus.OPEN
ticket.resolved_at = None # clear resolved timestamp
# Clear SLA suppression so the re-opened ticket can breach again if neglected
from app.services.sla_service import clear_sla_notification
clear_sla_notification(ticket.id)
# Post a system comment documenting the re-open
reopen_body = render_comment_body(
f'**Issue has returned — ticket re-opened**\n\n{reason}'
)
comment = Comment(
ticket_id = ticket.id,
author_id = current_user.id,
body = reopen_body,
is_internal= False,
)
db.session.add(comment)
db.session.flush()
log_ticket_history(ticket, 'status', old_status, TicketStatus.OPEN, current_user.id)
log_action(current_user.id, 'ticket_reopen', 'ticket', ticket.id,
f'previous_status={old_status} reason_len={len(reason)}')
db.session.commit()
logger.info(f'[TICKET REOPEN] ticket_id={ticket.id} '
f'previous_status={old_status} by user_id={current_user.id}')
# Notify IT staff that the ticket has been re-opened
notify_status_change(ticket, old_status, current_user)
flash('Ticket re-opened. IT staff have been notified.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# ─── Canned Responses API (IT only) ──────────────────────────────────────────
@tickets_bp.route('/canned-responses')
@login_required
def get_canned_responses():
"""Return active canned responses as JSON for the comment form picker."""
if not current_user.is_it_staff:
abort(403)
responses = CannedResponse.query.filter_by(is_active=True).order_by(
CannedResponse.category, CannedResponse.title
).all()
return __import__('flask').jsonify({'responses': [
{
'id' : r.id,
'title' : r.title,
'body' : r.body,
'category': r.category or '',
}
for r in responses
]})
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime':
"""Return the SLA due datetime for a ticket based on its priority.
Thresholds are read from SystemSetting so admins can tune them in-app
without a code deploy. Falls back to config values if settings are absent.
"""
from app.models import SystemSetting
hours_map = {
TicketPriority.CRITICAL: int(SystemSetting.get('sla_critical_hours', current_app.config.get('SLA_CRITICAL_HOURS', 4))),
TicketPriority.HIGH: int(SystemSetting.get('sla_high_hours', current_app.config.get('SLA_HIGH_HOURS', 8))),
TicketPriority.MEDIUM: int(SystemSetting.get('sla_medium_hours', current_app.config.get('SLA_MEDIUM_HOURS', 48))),
TicketPriority.LOW: int(SystemSetting.get('sla_low_hours', current_app.config.get('SLA_LOW_HOURS', 120))),
}
from datetime import timedelta
hours = hours_map.get(priority, 48)
return datetime.utcnow() + timedelta(hours=hours)
def _statuses():
return [TicketStatus.OPEN, TicketStatus.IN_PROGRESS,
TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.CLOSED]