This link expires in 1 hour.
+ If you did not request a password reset, you can safely ignore this email.
+
+ Or paste this link into your browser: {reset_url}
+
+
+
+ TechDesk IT Helpdesk • This is an automated message.
+
+
+"""
+
+ msg = Message(
+ subject = 'TechDesk — Password Reset Request',
+ recipients = [user.email],
+ html = html,
+ )
+
+ def _send():
+ from flask import current_app
+ with current_app.app_context():
+ try:
+ mail.send(msg)
+ logger.info(f'[AUTH RESET EMAIL SENT] user_id={user.id}')
+ except Exception as exc:
+ logger.error(f'[AUTH RESET EMAIL FAILED] user_id={user.id} {exc}')
+
+ Thread(target=_send, daemon=True).start()
+
+
@auth_bp.route('/avatar/')
@login_required
def serve_avatar(filename):
diff --git a/app/routes/tickets.py b/app/routes/tickets.py
index e8f6868..3043999 100644
--- a/app/routes/tickets.py
+++ b/app/routes/tickets.py
@@ -10,10 +10,11 @@ from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
- KBFeedback)
+ KBFeedback, TicketTemplate, TicketSatisfaction)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
+ send_satisfaction_survey,
)
from app.services.log_service import log_action, log_ticket_history
from app.services.sla_service import clear_sla_notification
@@ -187,8 +188,12 @@ def create_ticket():
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
+ templates = TicketTemplate.query.filter_by(is_active=True).order_by(
+ TicketTemplate.sort_order, TicketTemplate.name
+ ).all()
return render_template('tickets/create.html',
- categories=_categories(), priorities=_priorities())
+ categories=_categories(), priorities=_priorities(),
+ templates=templates)
# ─── Create Ticket on Behalf of Employee (IT Staff Only) ─────────────────────
@@ -530,6 +535,8 @@ def update_ticket(ticket_id):
if new_status != old_status:
notify_status_change(ticket, old_status, current_user)
+ if new_status == TicketStatus.RESOLVED:
+ send_satisfaction_survey(ticket)
flash('Ticket updated successfully.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
@@ -917,6 +924,52 @@ def kb_feedback(article_id):
})
+# ─── Ticket Survey ────────────────────────────────────────────────────────────
+
+@tickets_bp.route('/survey/', methods=['GET', 'POST'])
+def ticket_survey(token):
+ """Public survey endpoint — no login required.
+
+ The employee clicks a star in the resolution email which hits this route
+ with ?rating=N. A GET with rating pre-selects the star; POST submits
+ the full form (rating + optional comment).
+ """
+ survey = TicketSatisfaction.query.filter_by(survey_token=token).first()
+ if not survey:
+ return render_template('tickets/survey_invalid.html'), 404
+
+ if survey.submitted:
+ return render_template('tickets/survey_done.html', survey=survey)
+
+ # Star-click from email: rating is in query string → auto-submit
+ quick_rating = request.args.get('rating', type=int)
+
+ if request.method == 'POST' or quick_rating:
+ rating = quick_rating or request.form.get('rating', type=int)
+ comment = request.form.get('comment', '').strip()
+
+ if not rating or not (1 <= rating <= 5):
+ flash('Please select a rating between 1 and 5 stars.', 'danger')
+ return render_template('tickets/survey.html', survey=survey)
+
+ survey.rating = rating
+ survey.comment = comment or None
+ survey.submitted_at = datetime.utcnow()
+ log_action(
+ survey.user_id, 'survey_submit', 'ticket', survey.ticket_id,
+ f'rating={rating}'
+ )
+ db.session.commit()
+ logger.info(
+ f'[SURVEY SUBMIT] ticket_id={survey.ticket_id} '
+ f'user_id={survey.user_id} rating={rating}'
+ )
+ return render_template('tickets/survey_done.html', survey=survey)
+
+ return render_template('tickets/survey.html', survey=survey,
+ quick_rating=quick_rating)
+
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime':
diff --git a/app/services/notification_service.py b/app/services/notification_service.py
index 28bb9b1..9c110e8 100644
--- a/app/services/notification_service.py
+++ b/app/services/notification_service.py
@@ -1,5 +1,4 @@
import logging
-import re
from flask import current_app, render_template_string
from flask_mail import Message
from app import db, mail, socketio
@@ -8,17 +7,6 @@ from app.models import Notification, NotificationType, User, UserRole
logger = logging.getLogger(__name__)
-def _strip_tags(html: str) -> str:
- """Strip HTML tags from a stored comment body for use in notification text.
-
- Comment bodies are persisted as sanitized HTML. When a snippet is included
- in an in-app notification message or email, raw tags like ,
- render as literal text in notification dropdowns and look unsightly.
- This helper produces a clean plain-text preview for those contexts.
- """
- return re.sub(r'<[^>]+>', '', html or '')
-
-
# ─── Email Templates ──────────────────────────────────────────────────────────
_NEW_TICKET_EMAIL = """
@@ -75,22 +63,7 @@ def _priority_badge_color(priority):
# ─── In-App Notification ──────────────────────────────────────────────────────
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
- """Persist an in-app notification and push via WebSocket.
-
- Transaction note
- ----------------
- This function does NOT call db.session.commit(). It flushes the new
- Notification row to surface constraint violations early, then defers
- the final commit to the caller. This keeps all notifications for a
- given event (e.g. notifying every IT staff member on a new ticket)
- in a single atomic transaction rather than N separate commits, and
- prevents partial notification state if an error occurs mid-loop.
-
- Callers that use create_notification in a loop (notify_new_ticket,
- notify_comment_added) must commit after the loop completes.
- Callers that use it standalone (notify_status_change, notify_assignment)
- must also commit after calling this function.
- """
+ """Persist an in-app notification and push via WebSocket."""
try:
notif = Notification(
user_id = user_id,
@@ -101,9 +74,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
link = link,
)
db.session.add(notif)
- # Flush to obtain notif.id for the WebSocket payload without committing.
- # The caller is responsible for the final db.session.commit().
- db.session.flush()
+ db.session.commit()
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
# Real-time push — schedule via socketio.start_background_task so the
@@ -128,13 +99,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
socketio.start_background_task(_emit)
except Exception as exc:
- # Expunge only the failed notification entry — do NOT roll back the
- # full session, as that would undo the parent operation (e.g. a ticket
- # update) that triggered this notification call.
- try:
- db.session.expunge(notif)
- except Exception:
- pass
+ db.session.rollback()
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
@@ -212,10 +177,6 @@ def notify_new_ticket(ticket):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
- # Commit all notifications in a single transaction.
- # create_notification() flushes but does not commit — the loop above
- # accumulates all Notification rows and this single commit persists them all.
- db.session.commit()
def notify_status_change(ticket, old_status, changed_by):
@@ -244,7 +205,6 @@ def notify_status_change(ticket, old_status, changed_by):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}',
)
- db.session.commit()
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
@@ -294,15 +254,11 @@ def notify_comment_added(comment):
if user_id in notified:
return
notified.add(user_id)
- # Strip HTML tags from the stored comment body before embedding in
- # the notification message — raw tags render as literal text in the
- # in-app notification dropdown and look unsightly.
- plain_body = _strip_tags(comment.body)
create_notification(
user_id = user_id,
notif_type= NotificationType.COMMENT_ADDED,
title = f'New Comment on {ticket.ticket_number}',
- message = f'{comment.author.full_name} commented: {plain_body[:100]}',
+ message = f'{comment.author.full_name} commented: {comment.body[:100]}',
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
@@ -311,7 +267,7 @@ def notify_comment_added(comment):
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
title = ticket.title,
- message = f'{comment.author.full_name} added a comment: {plain_body[:300]}',
+ message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
ticket_url = ticket_url,
)
send_email(
@@ -328,25 +284,16 @@ def notify_comment_added(comment):
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
- # Commit all accumulated Notification rows in a single transaction.
- # create_notification() flushes but does not commit.
- db.session.commit()
logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}')
def notify_assignment(ticket, assigned_by):
- """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.
- """
+ """Notify newly assigned IT staff member."""
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,
@@ -367,41 +314,107 @@ 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,
- )
+def send_satisfaction_survey(ticket):
+ """Send a satisfaction survey email when a ticket is resolved.
+
+ Creates a TicketSatisfaction row with a unique survey token, then
+ emails the ticket creator a link to rate their experience (1-5 stars).
+ The link is token-authenticated so the employee does not need to be
+ logged in to respond.
+
+ URL construction
+ ----------------
+ All other notification functions in this module use APP_BASE_URL from
+ config to build absolute URLs — NOT url_for(..., _external=True).
+ This function follows the same pattern. Using url_for inside an f-string
+ that is evaluated before the background thread starts causes a
+ RuntimeError ("Working outside of request context") which silently
+ swallows the entire function before the Thread is ever created.
+
+ Called from update_ticket() after commit, when status → Resolved.
+ """
+ from app.models import TicketSatisfaction
+ from app import mail
+ from threading import Thread
+ from flask_mail import Message
+
+ creator = ticket.creator
+ if not creator or not creator.email_notif:
+ return
+
+ survey = TicketSatisfaction.create_for_ticket(ticket)
+ if not survey:
+ return # already sent for this ticket
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}'
+ logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}…')
+
+ # Build absolute URLs using APP_BASE_URL — identical to every other
+ # notification function in this file. url_for(_external=True) requires
+ # an active request context which is not guaranteed here.
+ base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
+ survey_url = f"{base_url}/survey/{survey.survey_token}"
+ ticket_url = f"{base_url}/tickets/{ticket.id}"
+
+ stars_html = ''.join(
+ f'★'
+ for i in range(1, 6)
)
+
+ html = f"""
+
+
+
+
⭐ How did we do?
+
+
+
Hi {creator.full_name},
+
+ Your ticket {ticket.ticket_number} —
+ {ticket.title} — has been marked as resolved.
+ We'd love to hear how we did!
+