diff --git a/app/__init__.py b/app/__init__.py index 047a43d..d4af6d1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -180,6 +180,7 @@ def create_app(config_name=None): db.create_all() _seed_admin(app) _seed_settings() + _seed_ticket_templates() # ── Email ingestion background scheduler ──────────────────────────────────── _start_email_ingestion_scheduler(app) @@ -265,6 +266,50 @@ def _start_email_ingestion_scheduler(app): app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}') + + +def _seed_ticket_templates(): + """Seed a starter set of ticket templates if none exist.""" + from app.models import TicketTemplate + if TicketTemplate.query.first(): + return # already seeded + defaults = [ + dict(name='VPN / Remote Access Issue', category='network', priority='high', + icon='bi-shield-lock', + title_hint='Cannot connect to VPN', + description='Steps to reproduce:\n1. \n\nError message:\n\nOperating system:\n\nLast time it worked:', + sort_order=1), + dict(name='New Software Request', category='software', priority='low', + icon='bi-box-arrow-in-down', + title_hint='Software installation request — ', + description='Software name and version:\n\nBusiness justification:\n\nApproved by (manager):', + sort_order=2), + dict(name='Password / Account Access', category='access', priority='medium', + icon='bi-key', + title_hint='Cannot log in to ', + description='System / application:\n\nError message:\n\nLast successful login:', + sort_order=3), + dict(name='Hardware Issue', category='hardware', priority='medium', + icon='bi-pc-display', + title_hint='Hardware problem — ', + description='Device type and asset tag:\n\nSymptoms:\n\nWhen did it start:', + sort_order=4), + dict(name='New Employee Onboarding', category='access', priority='high', + icon='bi-person-plus', + title_hint='New employee setup — ', + description='Employee name:\nStart date:\nDepartment:\nManager:\n\nAccounts needed:\n- Email\n- VPN\n- Other:', + sort_order=5), + dict(name='Printer / Scanner Issue', category='printer', priority='low', + icon='bi-printer', + title_hint='Printer not working — ', + description='Printer name / location:\n\nError message:\n\nComputer OS:', + sort_order=6), + ] + for d in defaults: + db.session.add(TicketTemplate(**d, is_active=True)) + db.session.commit() + + def _seed_settings(): """Ensure all required system settings exist with safe defaults.""" from app.models import SystemSetting diff --git a/app/models.py b/app/models.py index e6439c0..5f37862 100644 --- a/app/models.py +++ b/app/models.py @@ -453,3 +453,134 @@ class KBFeedback(db.Model): def __repr__(self): return f'' + + +class PasswordResetToken(db.Model): + """Single-use time-limited token for self-service password reset. + + Tokens are stored hashed (SHA-256) so a database breach does not + expose valid reset links. Each token expires after 1 hour and is + deleted on first use. + """ + __tablename__ = 'password_reset_tokens' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', + ondelete='CASCADE'), nullable=False) + token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', foreign_keys=[user_id]) + + @classmethod + def generate(cls, user_id): + """Create a new token, persist it, and return the raw token string. + Caller must commit the session. + """ + import secrets, hashlib + from datetime import timedelta + raw = secrets.token_urlsafe(32) + hashed= hashlib.sha256(raw.encode()).hexdigest() + token = cls( + user_id = user_id, + token_hash = hashed, + expires_at = datetime.utcnow() + timedelta(hours=1), + ) + db.session.add(token) + return raw, token + + @classmethod + def verify(cls, raw): + """Return the token row if raw is valid and unexpired, else None.""" + import hashlib + hashed = hashlib.sha256(raw.encode()).hexdigest() + row = cls.query.filter_by(token_hash=hashed).first() + if row and row.expires_at > datetime.utcnow(): + return row + return None + + def __repr__(self): + return f'' + + +class TicketTemplate(db.Model): + """Pre-filled ticket scaffolds selectable by employees on the New Ticket form. + + Templates reduce friction for common request types (e.g. VPN Access, + New Laptop Setup) and improve ticket quality by pre-populating category, + priority, and a structured description prompt. + """ + __tablename__ = 'ticket_templates' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, nullable=False, default='') + # Pre-filled form values + category = db.Column(db.String(50), nullable=False, default='other') + priority = db.Column(db.String(20), nullable=False, default='medium') + title_hint = db.Column(db.String(200), nullable=False, default='') + # UI grouping + icon = db.Column(db.String(40), nullable=False, default='bi-file-text') + is_active = db.Column(db.Boolean, default=True, nullable=False) + sort_order = db.Column(db.Integer, default=0) + created_by = db.Column(db.Integer, db.ForeignKey('users.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow) + + creator = db.relationship('User', foreign_keys=[created_by]) + + def __repr__(self): + return f'' + + +class TicketSatisfaction(db.Model): + """One satisfaction rating per resolved ticket. + + Sent automatically when a ticket moves to Resolved status. + The rating (1-5 stars) and optional comment are submitted via a + token-authenticated endpoint so the employee does not need to be + logged in to respond (they click from the email). + """ + __tablename__ = 'ticket_satisfaction' + + id = db.Column(db.Integer, primary_key=True) + ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id', + ondelete='CASCADE'), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', + ondelete='CASCADE'), nullable=False) + # survey_token authenticates the survey URL in the email — single-use + survey_token= db.Column(db.String(64), unique=True, nullable=False, index=True) + rating = db.Column(db.Integer) # 1-5; NULL until submitted + comment = db.Column(db.Text) + sent_at = db.Column(db.DateTime, default=datetime.utcnow) + submitted_at= db.Column(db.DateTime) + + ticket = db.relationship('Ticket', + backref=db.backref('satisfaction', uselist=False, + cascade='all, delete-orphan')) + user = db.relationship('User', foreign_keys=[user_id]) + + @classmethod + def create_for_ticket(cls, ticket): + """Create a survey row for a newly-resolved ticket. + Returns None if a survey already exists. Caller must commit. + """ + import secrets + if cls.query.filter_by(ticket_id=ticket.id).first(): + return None + row = cls( + ticket_id = ticket.id, + user_id = ticket.created_by_id, + survey_token = secrets.token_urlsafe(32), + ) + db.session.add(row) + return row + + @property + def submitted(self): + return self.rating is not None + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index 390a950..0b188e3 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -11,7 +11,8 @@ 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, CannedResponse) + KBAttachment, UserRole, TicketStatus, CannedResponse, + TicketTemplate, TicketSatisfaction) from app.services.log_service import log_action, log_ticket_history from app.services.validation_service import validate_password, validate_file @@ -1033,6 +1034,127 @@ def bulk_ticket_action(): )) +# ─── Ticket Templates ──────────────────────────────────────────────────────── + +@admin_bp.route('/ticket-templates') +@login_required +@it_required +def ticket_templates(): + templates = TicketTemplate.query.order_by( + TicketTemplate.sort_order, TicketTemplate.name + ).all() + return render_template('admin/ticket_templates.html', templates=templates) + + +@admin_bp.route('/ticket-templates/new', methods=['GET', 'POST']) +@login_required +@it_required +def ticket_template_new(): + if request.method == 'POST': + name = request.form.get('name', '').strip() + category = request.form.get('category', 'other') + priority = request.form.get('priority', 'medium') + title_hint = request.form.get('title_hint', '').strip() + description= request.form.get('description', '').strip() + icon = request.form.get('icon', 'bi-file-text').strip() + sort_order = request.form.get('sort_order', 0, type=int) + is_active = bool(request.form.get('is_active')) + if not name: + flash('Template name is required.', 'danger') + return render_template('admin/ticket_template_edit.html', + t=None, categories=_categories_list(), + priorities=_priorities_list()) + t = TicketTemplate( + name=name, category=category, priority=priority, + title_hint=title_hint, description=description, + icon=icon, sort_order=sort_order, is_active=is_active, + created_by=current_user.id, + ) + db.session.add(t) + db.session.flush() + log_action(current_user.id, 'ticket_template_create', 'ticket_template', t.id, + f'name={name}') + db.session.commit() + logger.info(f'[TEMPLATE CREATE] id={t.id} name={name} by user_id={current_user.id}') + flash(f'Template "{name}" created.', 'success') + return redirect(url_for('admin.ticket_templates')) + return render_template('admin/ticket_template_edit.html', + t=None, categories=_categories_list(), + priorities=_priorities_list()) + + +@admin_bp.route('/ticket-templates//edit', methods=['GET', 'POST']) +@login_required +@it_required +def ticket_template_edit(tmpl_id): + t = db.session.get(TicketTemplate, tmpl_id) or abort(404) + if request.method == 'POST': + t.name = request.form.get('name', t.name).strip() + t.category = request.form.get('category', t.category) + t.priority = request.form.get('priority', t.priority) + t.title_hint = request.form.get('title_hint', '').strip() + t.description = request.form.get('description', '').strip() + t.icon = request.form.get('icon', 'bi-file-text').strip() + t.sort_order = request.form.get('sort_order', 0, type=int) + t.is_active = bool(request.form.get('is_active')) + log_action(current_user.id, 'ticket_template_edit', 'ticket_template', t.id, + f'name={t.name}') + db.session.commit() + logger.info(f'[TEMPLATE EDIT] id={t.id} by user_id={current_user.id}') + flash(f'Template "{t.name}" updated.', 'success') + return redirect(url_for('admin.ticket_templates')) + return render_template('admin/ticket_template_edit.html', + t=t, categories=_categories_list(), + priorities=_priorities_list()) + + +@admin_bp.route('/ticket-templates//delete', methods=['POST']) +@login_required +@it_required +def ticket_template_delete(tmpl_id): + t = db.session.get(TicketTemplate, tmpl_id) or abort(404) + name = t.name + log_action(current_user.id, 'ticket_template_delete', 'ticket_template', t.id, + f'name={name}') + logger.info(f'[TEMPLATE DELETE] id={t.id} name={name} by user_id={current_user.id}') + db.session.delete(t) + db.session.commit() + flash(f'Template "{name}" deleted.', 'success') + return redirect(url_for('admin.ticket_templates')) + + +# ─── Satisfaction Survey Report ─────────────────────────────────────────────── + +@admin_bp.route('/satisfaction') +@login_required +@it_required +def satisfaction_report(): + surveys = TicketSatisfaction.query.filter( + TicketSatisfaction.rating.isnot(None) + ).order_by(TicketSatisfaction.submitted_at.desc()).all() + total = len(surveys) + avg_rating = round(sum(s.rating for s in surveys) / total, 2) if total else None + dist = {i: sum(1 for s in surveys if s.rating == i) for i in range(1, 6)} + pending = TicketSatisfaction.query.filter_by(rating=None).count() + return render_template('admin/satisfaction_report.html', + surveys=surveys, total=total, + avg_rating=avg_rating, dist=dist, pending=pending) + + +def _categories_list(): + from app.models import TicketCategory + return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE, + TicketCategory.NETWORK, TicketCategory.ACCESS, + TicketCategory.EMAIL, TicketCategory.PRINTER, + TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER] + + +def _priorities_list(): + from app.models import TicketPriority + return [TicketPriority.LOW, TicketPriority.MEDIUM, + TicketPriority.HIGH, TicketPriority.CRITICAL] + + def _roles(): return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] diff --git a/app/routes/auth.py b/app/routes/auth.py index a1534c7..04b7471 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -188,6 +188,126 @@ def profile(): return render_template('auth/profile.html') + + +@auth_bp.route('/forgot-password', methods=['GET', 'POST']) +@limiter.limit('5 per hour') +def forgot_password(): + """Show the forgot-password form and send a reset email on POST.""" + if current_user.is_authenticated: + return redirect(url_for('tickets.dashboard')) + + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + user = User.query.filter_by(email=email, is_active=True).first() + + # Always show the same message — prevents user enumeration + flash('If that email is registered, a password reset link has been sent.', 'info') + + if user: + from app.models import PasswordResetToken + # Invalidate any existing unused tokens for this user + PasswordResetToken.query.filter_by(user_id=user.id).delete() + raw, _token = PasswordResetToken.generate(user.id) + db.session.commit() + logger.info(f'[AUTH RESET REQUEST] user_id={user.id} email={email}') + + reset_url = url_for('auth.reset_password', token=raw, _external=True) + _send_reset_email(user, reset_url) + + return redirect(url_for('auth.login')) + + return render_template('auth/forgot_password.html') + + +@auth_bp.route('/reset-password/', methods=['GET', 'POST']) +@limiter.limit('10 per hour') +def reset_password(token): + """Validate the reset token and allow the user to set a new password.""" + if current_user.is_authenticated: + return redirect(url_for('tickets.dashboard')) + + from app.models import PasswordResetToken + token_row = PasswordResetToken.verify(token) + if not token_row: + flash('This password reset link is invalid or has expired. Please request a new one.', 'danger') + return redirect(url_for('auth.forgot_password')) + + if request.method == 'POST': + password = request.form.get('password', '') + confirm = request.form.get('confirm_password', '') + pw_error = validate_password(password, confirm) + if pw_error: + flash(pw_error, 'danger') + return render_template('auth/reset_password.html', token=token) + + user = token_row.user + user.set_password(password) + db.session.delete(token_row) # single-use — delete immediately + log_action(user.id, 'password_reset', 'user', user.id) + db.session.commit() + logger.info(f'[AUTH RESET COMPLETE] user_id={user.id}') + flash('Password updated successfully. You may now log in.', 'success') + return redirect(url_for('auth.login')) + + return render_template('auth/reset_password.html', token=token) + + +def _send_reset_email(user, reset_url): + """Send the password reset email in a background thread.""" + from threading import Thread + from flask_mail import Message + from app import mail + + html = f""" + +
+
+

🔐 Password Reset Request

+
+
+

Hi {user.full_name},

+

We received a request to reset your TechDesk password. + Click the button below to choose a new one.

+

+ + Reset My Password + +

+

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! +

+

+ Click a star to rate your experience: +

+

+ {stars_html} +

+

+ + Or leave a detailed comment + +

+

+ If you feel the issue is not fully resolved, you can + re-open your ticket + at any time. +

+
+
+ TechDesk IT Helpdesk • This is an automated message. +
+
+""" + + msg = Message( + subject = f'[TechDesk] How did we do? — {ticket.ticket_number}', + recipients = [creator.email], + html = html, + ) + + def _send(): + with current_app.app_context(): + try: + mail.send(msg) + logger.info(f'[SURVEY EMAIL SENT] ticket_id={ticket.id} user_id={creator.id}') + except Exception as exc: + logger.error(f'[SURVEY EMAIL FAILED] ticket_id={ticket.id} {exc}') + + Thread(target=_send, daemon=True).start() \ No newline at end of file diff --git a/app/templates/admin/satisfaction_report.html b/app/templates/admin/satisfaction_report.html new file mode 100644 index 0000000..545dfd0 --- /dev/null +++ b/app/templates/admin/satisfaction_report.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% block title %}Satisfaction Report{% endblock %} +{% block page_title %}Satisfaction Report{% endblock %} + +{% block content %} + + +
+
+
+
+ +
+
+
+ {% if avg_rating %}{{ avg_rating }} / 5{% else %}—{% endif %} +
+
Average Rating
+
+
+
+
+
+
+ +
+
+
{{ total }}
+
Responses Received
+
+
+
+
+
+
+ +
+
+
{{ pending }}
+
Awaiting Response
+
+
+
+
+ + +{% if total %} +
+
Rating Distribution
+
+ {% for star in [5,4,3,2,1] %} + {% set count = dist[star] %} + {% set pct = ((count / total) * 100)|round(1) if total else 0 %} +
+
+ {{ star }} ★ +
+
+
+
+
+ {{ count }} ({{ pct }}%) +
+
+ {% endfor %} +
+
+{% endif %} + + +
+
Individual Responses
+
+ {% if surveys %} + + + + + + + + + + + + {% for s in surveys %} + + + + + + + + {% endfor %} + +
TicketSubmitted ByRatingCommentSubmitted
+ + {{ s.ticket.ticket_number }} + +
+ {{ s.ticket.title }} +
+
{{ s.user.full_name }} + + {% for i in range(s.rating) %}★{% endfor %}{% for i in range(5 - s.rating) %}{% endfor %} + + + {{ s.comment or '—' }} + + {{ s.submitted_at | localtime("%b %d, %Y") }} +
+ {% else %} +
+ + No satisfaction responses yet. Surveys are sent automatically when tickets are resolved. +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/ticket_template_edit.html b/app/templates/admin/ticket_template_edit.html new file mode 100644 index 0000000..1ad85a7 --- /dev/null +++ b/app/templates/admin/ticket_template_edit.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if t else 'New' }} Template{% endblock %} +{% block page_title %}{{ 'Edit' if t else 'New' }} Ticket Template{% endblock %} + +{% block content %} +
+
+
+
+ + {{ 'Edit: ' + t.name if t else 'New Template' }} +
+
+
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + + + +
+
+ Browse icons at + + icons.getbootstrap.com + +
+
+
+ + +
Lower = shown first
+
+
+
+ + +
+
+
+ +
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/ticket_templates.html b/app/templates/admin/ticket_templates.html new file mode 100644 index 0000000..93bce0e --- /dev/null +++ b/app/templates/admin/ticket_templates.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Ticket Templates{% endblock %} +{% block page_title %}Ticket Templates{% endblock %} + +{% block content %} +
+

+ Pre-filled ticket scaffolds that employees can select on the New Ticket form to speed up submission. +

+ + New Template + +
+ +
+
+ Templates + ({{ templates|length }}) +
+
+ {% if templates %} + + + + + + + + + + + + + + + {% for t in templates %} + + + + + + + + + + + {% endfor %} + +
NameCategoryPriorityTitle HintOrderStatus
{{ t.name }}{{ t.category.replace('_',' ').title() }}{{ t.priority.upper() }} + {{ t.title_hint or '—' }} + {{ t.sort_order }} + {% if t.is_active %} + Active + {% else %} + Inactive + {% endif %} + +
+ +
+ + +
+
+
+ {% else %} +
+ + No templates yet. Create your first template → +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/auth/forgot_password.html b/app/templates/auth/forgot_password.html new file mode 100644 index 0000000..41ee5f1 --- /dev/null +++ b/app/templates/auth/forgot_password.html @@ -0,0 +1,65 @@ + + + + + Forgot Password — {{ branding.app_name }} + + + + + + +
+
+
+ {% if branding.logo_stored_name %} + {{ branding.app_name }} + {% else %} + + {% endif %} +

Forgot Password?

+

Enter your email and we'll send you a reset link.

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + +
+ +
+ + +
+ +
+ +
+ + diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index a97ba75..a5ceaa1 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -78,8 +78,9 @@ - diff --git a/app/templates/auth/reset_password.html b/app/templates/auth/reset_password.html new file mode 100644 index 0000000..d6559e8 --- /dev/null +++ b/app/templates/auth/reset_password.html @@ -0,0 +1,99 @@ + + + + + Reset Password — {{ branding.app_name }} + + + + + + +
+
+
+ {% if branding.logo_stored_name %} + {{ branding.app_name }} + {% else %} + + {% endif %} +

Set New Password

+

Choose a strong password for your account.

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + +
+ +
+ + +
+
+
+
+
+
+ + +
+ +
+ +
+ + + diff --git a/app/templates/base.html b/app/templates/base.html index 6282702..38549b8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -329,15 +329,9 @@
  • All Tickets
  • -
  • - File on Behalf -
  • Manage KB
  • -
  • - Quick Replies -
  • {% if current_user.is_admin %}
  • Users @@ -345,6 +339,12 @@
  • Activity Logs
  • +
  • + Ticket Templates +
  • +
  • + Satisfaction +
  • Settings
  • diff --git a/app/templates/tickets/create.html b/app/templates/tickets/create.html index 33ec25a..d47fd2b 100644 --- a/app/templates/tickets/create.html +++ b/app/templates/tickets/create.html @@ -5,6 +5,31 @@ {% block content %}
    + {% if templates %} +
    +
    Start from a Template
    +
    +

    + Select a common request type to pre-fill the form, or fill it in manually below. +

    +
    + {% for t in templates %} +
    + +
    + {% endfor %} +
    +
    +
    + {% endif %} +
    New Support Request @@ -108,4 +133,35 @@
    +{% block scripts %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index 9681a91..c252ca0 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -752,6 +752,32 @@ function buildCommentEl(c) {
    + +{% if ticket.satisfaction %} +
    +
    Customer Satisfaction
    +
    + {% if ticket.satisfaction.submitted %} +
    + {% for i in range(ticket.satisfaction.rating) %}★{% endfor %}{% for i in range(5 - ticket.satisfaction.rating) %}★{% endfor %} +
    +
    + Rated {{ ticket.satisfaction.rating }}/5 on {{ ticket.satisfaction.submitted_at | localtime("%b %d, %Y") }} +
    + {% if ticket.satisfaction.comment %} +
    + "{{ ticket.satisfaction.comment }}" +
    + {% endif %} + {% else %} +
    + Survey sent — awaiting response +
    + {% endif %} +
    +
    +{% endif %} + {% if history %}
    Change History
    diff --git a/app/templates/tickets/survey.html b/app/templates/tickets/survey.html new file mode 100644 index 0000000..5b782cc --- /dev/null +++ b/app/templates/tickets/survey.html @@ -0,0 +1,90 @@ + + + + + How did we do? — TechDesk + + + + + +
    +
    +

    ⭐ How did we do?

    +

    Your feedback helps us improve our IT support.

    + +
    + {{ survey.ticket.ticket_number }} — {{ survey.ticket.title }} +
    + + {% if error %} +
    {{ error }}
    + {% endif %} + +
    + + +
    + +

    Click a star to rate

    +
    + {% for i in [5,4,3,2,1] %} + + + {% endfor %} +
    +
    + +
    + + +
    + + +
    +
    + + + diff --git a/app/templates/tickets/survey_done.html b/app/templates/tickets/survey_done.html new file mode 100644 index 0000000..573c5f7 --- /dev/null +++ b/app/templates/tickets/survey_done.html @@ -0,0 +1,41 @@ + + + + + Thank You — TechDesk + + + + +
    +
    +
    🙏
    +

    Thank you for your feedback!

    +

    Your response has been recorded and will help us improve our IT support.

    + + {% if survey.rating %} +
    + {% for i in range(survey.rating) %}★{% endfor %} + {% for i in range(5 - survey.rating) %}★{% endfor %} +
    + {% if survey.comment %} +
    "{{ survey.comment }}"
    + {% endif %} + {% endif %} + +

    + You can close this page. If the issue recurs, please submit a new ticket. +

    +
    + + diff --git a/app/templates/tickets/survey_invalid.html b/app/templates/tickets/survey_invalid.html new file mode 100644 index 0000000..d4a3c39 --- /dev/null +++ b/app/templates/tickets/survey_invalid.html @@ -0,0 +1,24 @@ + + + + + Survey Not Found — TechDesk + + + + +
    +
    +
    🔗
    +

    This survey link is invalid

    +

    The link may have already been used, or it does not exist. No further action is needed.

    +
    + +