04/07 updated password reset, ticket templates, and satisfaction survey
This commit is contained in:
@@ -180,6 +180,7 @@ def create_app(config_name=None):
|
|||||||
db.create_all()
|
db.create_all()
|
||||||
_seed_admin(app)
|
_seed_admin(app)
|
||||||
_seed_settings()
|
_seed_settings()
|
||||||
|
_seed_ticket_templates()
|
||||||
|
|
||||||
# ── Email ingestion background scheduler ────────────────────────────────────
|
# ── Email ingestion background scheduler ────────────────────────────────────
|
||||||
_start_email_ingestion_scheduler(app)
|
_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}')
|
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():
|
def _seed_settings():
|
||||||
"""Ensure all required system settings exist with safe defaults."""
|
"""Ensure all required system settings exist with safe defaults."""
|
||||||
from app.models import SystemSetting
|
from app.models import SystemSetting
|
||||||
|
|||||||
+131
@@ -453,3 +453,134 @@ class KBFeedback(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<KBFeedback article={self.article_id} user={self.user_id} helpful={self.is_helpful}>'
|
return f'<KBFeedback article={self.article_id} user={self.user_id} helpful={self.is_helpful}>'
|
||||||
|
|
||||||
|
|
||||||
|
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'<PasswordResetToken user={self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
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'<TicketTemplate {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
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'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
|
||||||
|
|||||||
+123
-1
@@ -11,7 +11,8 @@ from werkzeug.utils import secure_filename
|
|||||||
import bleach
|
import bleach
|
||||||
from app import db, limiter
|
from app import db, limiter
|
||||||
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
||||||
KBAttachment, UserRole, TicketStatus, CannedResponse)
|
KBAttachment, UserRole, TicketStatus, CannedResponse,
|
||||||
|
TicketTemplate, TicketSatisfaction)
|
||||||
from app.services.log_service import log_action, log_ticket_history
|
from app.services.log_service import log_action, log_ticket_history
|
||||||
from app.services.validation_service import validate_password, validate_file
|
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/<int:tmpl_id>/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/<int:tmpl_id>/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():
|
def _roles():
|
||||||
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
|
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,126 @@ def profile():
|
|||||||
return render_template('auth/profile.html')
|
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/<token>', 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"""
|
||||||
|
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||||
|
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:10px;
|
||||||
|
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
|
||||||
|
<div style="background:#1e293b;padding:24px 32px;">
|
||||||
|
<h1 style="color:#fff;margin:0;font-size:20px;">🔐 Password Reset Request</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="color:#334155;margin-top:0;">Hi {user.full_name},</p>
|
||||||
|
<p style="color:#334155;">We received a request to reset your TechDesk password.
|
||||||
|
Click the button below to choose a new one.</p>
|
||||||
|
<p style="text-align:center;margin:28px 0;">
|
||||||
|
<a href="{reset_url}"
|
||||||
|
style="display:inline-block;background:#2563eb;color:#fff;padding:13px 32px;
|
||||||
|
border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;">
|
||||||
|
Reset My Password
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#64748b;font-size:13px;">This link expires in <strong>1 hour</strong>.
|
||||||
|
If you did not request a password reset, you can safely ignore this email.</p>
|
||||||
|
<p style="color:#64748b;font-size:12px;word-break:break-all;">
|
||||||
|
Or paste this link into your browser:<br/>{reset_url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f8fafc;padding:16px 32px;text-align:center;
|
||||||
|
color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">
|
||||||
|
TechDesk IT Helpdesk • This is an automated message.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
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/<string:filename>')
|
@auth_bp.route('/avatar/<string:filename>')
|
||||||
@login_required
|
@login_required
|
||||||
def serve_avatar(filename):
|
def serve_avatar(filename):
|
||||||
|
|||||||
+55
-2
@@ -10,10 +10,11 @@ from app import db
|
|||||||
from app.models import (Ticket, Comment, Attachment, Notification,
|
from app.models import (Ticket, Comment, Attachment, Notification,
|
||||||
TicketStatus, TicketPriority, TicketCategory,
|
TicketStatus, TicketPriority, TicketCategory,
|
||||||
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
|
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
|
||||||
KBFeedback)
|
KBFeedback, TicketTemplate, TicketSatisfaction)
|
||||||
from app.services.notification_service import (
|
from app.services.notification_service import (
|
||||||
notify_new_ticket, notify_status_change,
|
notify_new_ticket, notify_status_change,
|
||||||
notify_comment_added, notify_assignment,
|
notify_comment_added, notify_assignment,
|
||||||
|
send_satisfaction_survey,
|
||||||
)
|
)
|
||||||
from app.services.log_service import log_action, log_ticket_history
|
from app.services.log_service import log_action, log_ticket_history
|
||||||
from app.services.sla_service import clear_sla_notification
|
from app.services.sla_service import clear_sla_notification
|
||||||
@@ -187,8 +188,12 @@ def create_ticket():
|
|||||||
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
|
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
|
||||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
|
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',
|
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) ─────────────────────
|
# ─── Create Ticket on Behalf of Employee (IT Staff Only) ─────────────────────
|
||||||
@@ -530,6 +535,8 @@ def update_ticket(ticket_id):
|
|||||||
|
|
||||||
if new_status != old_status:
|
if new_status != old_status:
|
||||||
notify_status_change(ticket, old_status, current_user)
|
notify_status_change(ticket, old_status, current_user)
|
||||||
|
if new_status == TicketStatus.RESOLVED:
|
||||||
|
send_satisfaction_survey(ticket)
|
||||||
|
|
||||||
flash('Ticket updated successfully.', 'success')
|
flash('Ticket updated successfully.', 'success')
|
||||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
|
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/<token>', 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 ─────────────────────────────────────────────────────────────────
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _sla_due_date(priority: str) -> 'datetime':
|
def _sla_due_date(priority: str) -> 'datetime':
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from flask import current_app, render_template_string
|
from flask import current_app, render_template_string
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from app import db, mail, socketio
|
from app import db, mail, socketio
|
||||||
@@ -8,17 +7,6 @@ from app.models import Notification, NotificationType, User, UserRole
|
|||||||
logger = logging.getLogger(__name__)
|
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 <strong>, <p>
|
|
||||||
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 ──────────────────────────────────────────────────────────
|
# ─── Email Templates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_NEW_TICKET_EMAIL = """
|
_NEW_TICKET_EMAIL = """
|
||||||
@@ -75,22 +63,7 @@ def _priority_badge_color(priority):
|
|||||||
# ─── In-App Notification ──────────────────────────────────────────────────────
|
# ─── In-App Notification ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
|
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
|
||||||
"""Persist an in-app notification and push via WebSocket.
|
"""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.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
notif = Notification(
|
notif = Notification(
|
||||||
user_id = user_id,
|
user_id = user_id,
|
||||||
@@ -101,9 +74,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
|
|||||||
link = link,
|
link = link,
|
||||||
)
|
)
|
||||||
db.session.add(notif)
|
db.session.add(notif)
|
||||||
# Flush to obtain notif.id for the WebSocket payload without committing.
|
db.session.commit()
|
||||||
# The caller is responsible for the final db.session.commit().
|
|
||||||
db.session.flush()
|
|
||||||
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
|
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
|
# 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)
|
socketio.start_background_task(_emit)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Expunge only the failed notification entry — do NOT roll back the
|
db.session.rollback()
|
||||||
# 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
|
|
||||||
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
|
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
|
||||||
|
|
||||||
|
|
||||||
@@ -212,10 +177,6 @@ def notify_new_ticket(ticket):
|
|||||||
ticket_id = ticket.id,
|
ticket_id = ticket.id,
|
||||||
link = f'/tickets/{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):
|
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,
|
ticket_id = ticket.id,
|
||||||
link = f'/tickets/{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}')
|
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:
|
if user_id in notified:
|
||||||
return
|
return
|
||||||
notified.add(user_id)
|
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(
|
create_notification(
|
||||||
user_id = user_id,
|
user_id = user_id,
|
||||||
notif_type= NotificationType.COMMENT_ADDED,
|
notif_type= NotificationType.COMMENT_ADDED,
|
||||||
title = f'New Comment on {ticket.ticket_number}',
|
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,
|
ticket_id = ticket.id,
|
||||||
link = f'/tickets/{ticket.id}#comment-{comment.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,
|
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||||
ticket_number = ticket.ticket_number,
|
ticket_number = ticket.ticket_number,
|
||||||
title = ticket.title,
|
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,
|
ticket_url = ticket_url,
|
||||||
)
|
)
|
||||||
send_email(
|
send_email(
|
||||||
@@ -328,25 +284,16 @@ def notify_comment_added(comment):
|
|||||||
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
|
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
|
||||||
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
|
_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}')
|
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):
|
def notify_assignment(ticket, assigned_by):
|
||||||
"""Notify newly assigned IT staff member AND the ticket creator (employee).
|
"""Notify newly assigned IT staff member."""
|
||||||
|
|
||||||
The employee who submitted the ticket (or on whose behalf it was filed)
|
|
||||||
receives a confirmation that their issue has been picked up, giving
|
|
||||||
them visibility without requiring them to poll the ticket page.
|
|
||||||
"""
|
|
||||||
if not ticket.assigned_to_id:
|
if not ticket.assigned_to_id:
|
||||||
return
|
return
|
||||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
# ── Notify the IT staff assignee ──────────────────────────────────────────
|
|
||||||
create_notification(
|
create_notification(
|
||||||
user_id = ticket.assigned_to_id,
|
user_id = ticket.assigned_to_id,
|
||||||
notif_type= NotificationType.TICKET_ASSIGNED,
|
notif_type= NotificationType.TICKET_ASSIGNED,
|
||||||
@@ -367,41 +314,107 @@ def notify_assignment(ticket, assigned_by):
|
|||||||
[ticket.assignee.email],
|
[ticket.assignee.email],
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
|
||||||
|
|
||||||
# ── Notify the ticket creator (employee) ──────────────────────────────────
|
def send_satisfaction_survey(ticket):
|
||||||
# Only notify if the creator is not the assignee — avoids a redundant
|
"""Send a satisfaction survey email when a ticket is resolved.
|
||||||
# self-notification when IT staff file and assign their own tickets.
|
|
||||||
if ticket.created_by_id != ticket.assigned_to_id:
|
Creates a TicketSatisfaction row with a unique survey token, then
|
||||||
assignee_name = ticket.assignee.full_name if ticket.assignee else 'an IT staff member'
|
emails the ticket creator a link to rate their experience (1-5 stars).
|
||||||
employee_msg = (
|
The link is token-authenticated so the employee does not need to be
|
||||||
f'Your ticket "{ticket.title}" has been picked up by {assignee_name}. '
|
logged in to respond.
|
||||||
f'You will be notified as soon as there is an update.'
|
|
||||||
)
|
URL construction
|
||||||
create_notification(
|
----------------
|
||||||
user_id = ticket.created_by_id,
|
All other notification functions in this module use APP_BASE_URL from
|
||||||
notif_type= NotificationType.TICKET_ASSIGNED,
|
config to build absolute URLs — NOT url_for(..., _external=True).
|
||||||
title = f'Ticket {ticket.ticket_number} — Now Being Worked On',
|
This function follows the same pattern. Using url_for inside an f-string
|
||||||
message = employee_msg,
|
that is evaluated before the background thread starts causes a
|
||||||
ticket_id = ticket.id,
|
RuntimeError ("Working outside of request context") which silently
|
||||||
link = f'/tickets/{ticket.id}',
|
swallows the entire function before the Thread is ever created.
|
||||||
)
|
|
||||||
creator = db.session.get(User, ticket.created_by_id)
|
Called from update_ticket() after commit, when status → Resolved.
|
||||||
if creator and creator.email_notif:
|
"""
|
||||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
from app.models import TicketSatisfaction
|
||||||
ticket_number = ticket.ticket_number,
|
from app import mail
|
||||||
title = ticket.title,
|
from threading import Thread
|
||||||
message = employee_msg,
|
from flask_mail import Message
|
||||||
ticket_url = ticket_url,
|
|
||||||
)
|
creator = ticket.creator
|
||||||
send_email(
|
if not creator or not creator.email_notif:
|
||||||
f'[Ticket Update] {ticket.ticket_number} — Now Being Worked On',
|
return
|
||||||
[creator.email],
|
|
||||||
html,
|
survey = TicketSatisfaction.create_for_ticket(ticket)
|
||||||
)
|
if not survey:
|
||||||
|
return # already sent for this ticket
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
logger.info(
|
logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}…')
|
||||||
f'[TICKET ASSIGN] ticket_id={ticket.id} '
|
|
||||||
f'assigned_to={ticket.assigned_to_id} by={assigned_by.id} '
|
# Build absolute URLs using APP_BASE_URL — identical to every other
|
||||||
f'employee_notified={ticket.created_by_id != ticket.assigned_to_id}'
|
# 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'<a href="{survey_url}?rating={i}" '
|
||||||
|
f'style="display:inline-block;margin:0 6px;font-size:38px;'
|
||||||
|
f'text-decoration:none;color:#f59e0b;" title="{i} star">★</a>'
|
||||||
|
for i in range(1, 6)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||||
|
<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;
|
||||||
|
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
|
||||||
|
<div style="background:#1e293b;padding:24px 32px;">
|
||||||
|
<h1 style="color:#fff;margin:0;font-size:20px;">⭐ How did we do?</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px;">
|
||||||
|
<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>
|
||||||
|
<p style="color:#334155;">
|
||||||
|
Your ticket <strong>{ticket.ticket_number}</strong> —
|
||||||
|
<em>{ticket.title}</em> — has been marked as resolved.
|
||||||
|
We'd love to hear how we did!
|
||||||
|
</p>
|
||||||
|
<p style="color:#334155;font-weight:600;margin-bottom:6px;">
|
||||||
|
Click a star to rate your experience:
|
||||||
|
</p>
|
||||||
|
<p style="text-align:center;margin:20px 0;line-height:1;">
|
||||||
|
{stars_html}
|
||||||
|
</p>
|
||||||
|
<p style="text-align:center;">
|
||||||
|
<a href="{survey_url}" style="color:#2563eb;font-size:13px;">
|
||||||
|
Or leave a detailed comment
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin-top:24px;">
|
||||||
|
If you feel the issue is not fully resolved, you can
|
||||||
|
<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>
|
||||||
|
at any time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="background:#f8fafc;padding:16px 32px;text-align:center;
|
||||||
|
color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">
|
||||||
|
TechDesk IT Helpdesk • This is an automated message.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Satisfaction Report{% endblock %}
|
||||||
|
{% block page_title %}Satisfaction Report{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- KPI row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(251,191,36,.1);color:#f59e0b;">
|
||||||
|
<i class="bi bi-star-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{% if avg_rating %}{{ avg_rating }} <span style="font-size:16px;">/ 5</span>{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">Average Rating</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(37,99,235,.1);color:var(--accent);">
|
||||||
|
<i class="bi bi-clipboard-check"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ total }}</div>
|
||||||
|
<div class="stat-label">Responses Received</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background:rgba(100,116,139,.1);color:var(--muted);">
|
||||||
|
<i class="bi bi-hourglass-split"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ pending }}</div>
|
||||||
|
<div class="stat-label">Awaiting Response</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating distribution -->
|
||||||
|
{% if total %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header"><i class="bi bi-bar-chart-fill me-2"></i>Rating Distribution</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for star in [5,4,3,2,1] %}
|
||||||
|
{% set count = dist[star] %}
|
||||||
|
{% set pct = ((count / total) * 100)|round(1) if total else 0 %}
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-2">
|
||||||
|
<div style="width:60px;text-align:right;font-size:13px;font-weight:600;color:var(--text2);flex-shrink:0;">
|
||||||
|
{{ star }} ★
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:var(--surface2);border-radius:4px;height:18px;overflow:hidden;">
|
||||||
|
<div style="width:{{ pct }}%;height:100%;background:{% if star >= 4 %}var(--success){% elif star == 3 %}var(--warning){% else %}var(--danger){% endif %};border-radius:4px;transition:width .4s;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="width:70px;font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">
|
||||||
|
{{ count }} ({{ pct }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Individual responses -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-chat-square-text me-2"></i>Individual Responses</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if surveys %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticket</th>
|
||||||
|
<th>Submitted By</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
<th>Comment</th>
|
||||||
|
<th>Submitted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in surveys %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tickets.ticket_detail', ticket_id=s.ticket_id) }}"
|
||||||
|
class="mono" style="font-size:11px;color:var(--accent3);">
|
||||||
|
{{ s.ticket.ticket_number }}
|
||||||
|
</a>
|
||||||
|
<div style="font-size:12px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
{{ s.ticket.title }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:13px;">{{ s.user.full_name }}</td>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:18px;color:#f59e0b;">
|
||||||
|
{% for i in range(s.rating) %}★{% endfor %}{% for i in range(5 - s.rating) %}<span style="color:var(--border2);">★</span>{% endfor %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);max-width:220px;">
|
||||||
|
{{ s.comment or '—' }}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:11px;color:var(--muted);">
|
||||||
|
{{ s.submitted_at | localtime("%b %d, %Y") }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-star" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No satisfaction responses yet. Surveys are sent automatically when tickets are resolved.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-layout-text-window-reverse me-2"></i>
|
||||||
|
{{ 'Edit: ' + t.name if t else 'New Template' }}
|
||||||
|
</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">Template Name *</label>
|
||||||
|
<input type="text" class="form-control" name="name" required
|
||||||
|
value="{{ t.name if t else '' }}"
|
||||||
|
placeholder="e.g. VPN Access Issue"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<select class="form-select" name="category">
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat }}" {% if t and t.category == cat %}selected{% endif %}>
|
||||||
|
{{ cat.replace('_',' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Default Priority</label>
|
||||||
|
<select class="form-select" name="priority">
|
||||||
|
{% for p in priorities %}
|
||||||
|
<option value="{{ p }}" {% if t and t.priority == p %}selected{% elif not t and p == 'medium' %}selected{% endif %}>
|
||||||
|
{{ p.upper() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Title Hint
|
||||||
|
<span style="font-size:11px;color:var(--muted);font-weight:400;">
|
||||||
|
— pre-fills the ticket title field
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" name="title_hint"
|
||||||
|
value="{{ t.title_hint if t else '' }}"
|
||||||
|
placeholder="e.g. Cannot connect to VPN"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description Template
|
||||||
|
<span style="font-size:11px;color:var(--muted);font-weight:400;">
|
||||||
|
— pre-fills the description textarea (use newlines as prompts)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control" name="description" rows="7"
|
||||||
|
placeholder="Steps to reproduce: 1. Error message: Device / OS:">{{ t.description if t else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Bootstrap Icon class
|
||||||
|
<span style="font-size:11px;color:var(--muted);font-weight:400;">
|
||||||
|
— e.g. bi-shield-lock
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text" id="icon-preview" style="font-size:18px;color:var(--accent);">
|
||||||
|
<i class="bi {{ t.icon if t else 'bi-file-text' }}" id="icon-el"></i>
|
||||||
|
</span>
|
||||||
|
<input type="text" class="form-control" name="icon" id="icon-input"
|
||||||
|
value="{{ t.icon if t else 'bi-file-text' }}"
|
||||||
|
oninput="document.getElementById('icon-el').className='bi '+this.value"/>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
Browse icons at
|
||||||
|
<a href="https://icons.getbootstrap.com/" target="_blank" style="color:var(--accent3);">
|
||||||
|
icons.getbootstrap.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Sort Order</label>
|
||||||
|
<input type="number" class="form-control" name="sort_order" min="0"
|
||||||
|
value="{{ t.sort_order if t else 0 }}"/>
|
||||||
|
<div class="form-text">Lower = shown first</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-center" style="padding-top:28px;">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch"
|
||||||
|
name="is_active" id="is-active"
|
||||||
|
{% if not t or t.is_active %}checked{% endif %}
|
||||||
|
style="width:40px;height:22px;cursor:pointer;"/>
|
||||||
|
<label class="form-check-label ms-2" for="is-active"
|
||||||
|
style="font-size:13px;font-weight:600;">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check2 me-2"></i>{{ 'Save Changes' if t else 'Create Template' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('admin.ticket_templates') }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Ticket Templates{% endblock %}
|
||||||
|
{% block page_title %}Ticket Templates{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin:0;">
|
||||||
|
Pre-filled ticket scaffolds that employees can select on the New Ticket form to speed up submission.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('admin.ticket_template_new') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-layout-text-window-reverse me-2"></i>Templates
|
||||||
|
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ templates|length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if templates %}
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:36px;"></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Title Hint</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in templates %}
|
||||||
|
<tr>
|
||||||
|
<td style="color:var(--accent);font-size:18px;"><i class="bi {{ t.icon }}"></i></td>
|
||||||
|
<td style="font-weight:600;font-size:13px;">{{ t.name }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
|
||||||
|
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
{{ t.title_hint or '—' }}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);">{{ t.sort_order }}</td>
|
||||||
|
<td>
|
||||||
|
{% if t.is_active %}
|
||||||
|
<span style="font-size:12px;color:var(--success);"><i class="bi bi-check-circle-fill"></i> Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:12px;color:var(--muted);"><i class="bi bi-dash-circle"></i> Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('admin.ticket_template_edit', tmpl_id=t.id) }}"
|
||||||
|
class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||||
|
<form method="POST" action="{{ url_for('admin.ticket_template_delete', tmpl_id=t.id) }}"
|
||||||
|
onsubmit="return confirm('Delete template "{{ t.name }}"?');" style="margin:0;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<button type="submit" class="btn btn-sm"
|
||||||
|
style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);"
|
||||||
|
title="Delete"><i class="bi bi-trash"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-5 text-center" style="color:var(--muted);">
|
||||||
|
<i class="bi bi-layout-text-window-reverse" style="font-size:40px;display:block;margin-bottom:12px;"></i>
|
||||||
|
No templates yet. <a href="{{ url_for('admin.ticket_template_new') }}" style="color:var(--accent3);">Create your first template →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Forgot Password — {{ branding.app_name }}</title>
|
||||||
|
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f0f4f8;--surface:#fff;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||||||
|
.brand{text-align:center;margin-bottom:28px;}
|
||||||
|
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
|
||||||
|
h1{font-size:20px;font-weight:700;color:var(--text);}
|
||||||
|
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
|
||||||
|
.form-group{margin-bottom:18px;}
|
||||||
|
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
|
||||||
|
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
|
||||||
|
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
input::placeholder{color:#94a3b8;}
|
||||||
|
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||||||
|
.btn:hover{background:var(--accent-h);}
|
||||||
|
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
|
||||||
|
.links a{color:var(--accent);}
|
||||||
|
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
|
||||||
|
.alert-info{background:#f0f9ff;color:#0284c7;border:1px solid #bae6fd;}
|
||||||
|
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
{% if branding.logo_stored_name %}
|
||||||
|
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
|
||||||
|
alt="{{ branding.app_name }}"
|
||||||
|
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
|
||||||
|
{% else %}
|
||||||
|
<div class="logo">{{ branding.logo_initials[:2] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<h1>Forgot Password?</h1>
|
||||||
|
<p class="subtitle">Enter your email and we'll send you a reset link.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }}"><i class="bi bi-info-circle me-2"></i>{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email Address</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn"><i class="bi bi-envelope me-2"></i>Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -78,8 +78,9 @@
|
|||||||
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
|
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="links">
|
<div class="links" style="display:flex;justify-content:space-between;">
|
||||||
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
|
<a href="{{ url_for('auth.forgot_password') }}" style="color:var(--muted);">Forgot password?</a>
|
||||||
|
<a href="{{ url_for('auth.register') }}">Register here</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Reset Password — {{ branding.app_name }}</title>
|
||||||
|
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f0f4f8;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;--danger:#dc2626;--danger-bg:#fef2f2;}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||||||
|
.brand{text-align:center;margin-bottom:28px;}
|
||||||
|
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
|
||||||
|
h1{font-size:20px;font-weight:700;color:var(--text);}
|
||||||
|
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
|
||||||
|
.form-group{margin-bottom:18px;}
|
||||||
|
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
|
||||||
|
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
|
||||||
|
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
input::placeholder{color:#94a3b8;}
|
||||||
|
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||||||
|
.btn:hover{background:var(--accent-h);}
|
||||||
|
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
|
||||||
|
.links a{color:var(--accent);}
|
||||||
|
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
|
||||||
|
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
|
||||||
|
/* strength meter */
|
||||||
|
#pw-strength-bar{height:4px;border-radius:2px;transition:width .3s,background .3s;background:#e2e8f0;width:0;}
|
||||||
|
#pw-strength-text{font-size:11px;color:var(--muted);margin-top:4px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
{% if branding.logo_stored_name %}
|
||||||
|
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
|
||||||
|
alt="{{ branding.app_name }}"
|
||||||
|
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
|
||||||
|
{% else %}
|
||||||
|
<div class="logo">{{ branding.logo_initials[:2] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<h1>Set New Password</h1>
|
||||||
|
<p class="subtitle">Choose a strong password for your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ cat }}"><i class="bi bi-exclamation-circle me-2"></i>{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" name="password" id="pw-new" required placeholder="Min. 8 characters" autofocus
|
||||||
|
oninput="checkStrength(this.value)"/>
|
||||||
|
<div style="margin-top:6px;background:#e2e8f0;border-radius:2px;height:4px;">
|
||||||
|
<div id="pw-strength-bar"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pw-strength-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm Password</label>
|
||||||
|
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn"><i class="bi bi-lock me-2"></i>Update Password</button>
|
||||||
|
</form>
|
||||||
|
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function checkStrength(pw) {
|
||||||
|
let score = 0;
|
||||||
|
if (pw.length >= 8) score++;
|
||||||
|
if (/[A-Z]/.test(pw)) score++;
|
||||||
|
if (/[0-9]/.test(pw)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||||
|
const bar = document.getElementById('pw-strength-bar');
|
||||||
|
const text = document.getElementById('pw-strength-text');
|
||||||
|
const levels = [
|
||||||
|
{ w: '0%', bg: '#e2e8f0', label: '' },
|
||||||
|
{ w: '25%', bg: '#dc2626', label: 'Weak' },
|
||||||
|
{ w: '50%', bg: '#d97706', label: 'Fair' },
|
||||||
|
{ w: '75%', bg: '#2563eb', label: 'Good' },
|
||||||
|
{ w: '100%', bg: '#059669', label: 'Strong' },
|
||||||
|
];
|
||||||
|
const lvl = levels[score] || levels[0];
|
||||||
|
bar.style.width = lvl.w;
|
||||||
|
bar.style.background = lvl.bg;
|
||||||
|
text.textContent = lvl.label;
|
||||||
|
text.style.color = lvl.bg;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -329,15 +329,9 @@
|
|||||||
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
|
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
|
||||||
<i class="bi bi-collection"></i> All Tickets
|
<i class="bi bi-collection"></i> All Tickets
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="{{ url_for('tickets.create_ticket_behalf') }}" class="{{ 'active' if request.endpoint == 'tickets.create_ticket_behalf' }}">
|
|
||||||
<i class="bi bi-person-plus"></i> File on Behalf
|
|
||||||
</a></li>
|
|
||||||
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
|
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
|
||||||
<i class="bi bi-journal-text"></i> Manage KB
|
<i class="bi bi-journal-text"></i> Manage KB
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="{{ url_for('admin.canned_responses') }}" class="{{ 'active' if 'canned_response' in request.endpoint }}">
|
|
||||||
<i class="bi bi-chat-square-text"></i> Quick Replies
|
|
||||||
</a></li>
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
|
||||||
<i class="bi bi-people"></i> Users
|
<i class="bi bi-people"></i> Users
|
||||||
@@ -345,6 +339,12 @@
|
|||||||
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
|
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
|
||||||
<i class="bi bi-list-ul"></i> Activity Logs
|
<i class="bi bi-list-ul"></i> Activity Logs
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('admin.ticket_templates') }}" class="{{ 'active' if 'ticket_template' in request.endpoint }}">
|
||||||
|
<i class="bi bi-layout-text-window-reverse"></i> Ticket Templates
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{ url_for('admin.satisfaction_report') }}" class="{{ 'active' if request.endpoint == 'admin.satisfaction_report' }}">
|
||||||
|
<i class="bi bi-star-half"></i> Satisfaction
|
||||||
|
</a></li>
|
||||||
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
|
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
|
||||||
<i class="bi bi-gear"></i> Settings
|
<i class="bi bi-gear"></i> Settings
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|||||||
@@ -5,6 +5,31 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
{% if templates %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-layout-text-window-reverse me-2"></i>Start from a Template</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin-bottom:14px;">
|
||||||
|
Select a common request type to pre-fill the form, or fill it in manually below.
|
||||||
|
</p>
|
||||||
|
<div class="row g-2">
|
||||||
|
{% for t in templates %}
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
|
||||||
|
style="padding:10px 14px;font-size:13px;"
|
||||||
|
data-title="{{ t.title_hint }}"
|
||||||
|
data-category="{{ t.category }}"
|
||||||
|
data-priority="{{ t.priority }}"
|
||||||
|
data-description="{{ t.description | e }}">
|
||||||
|
<i class="bi {{ t.icon }} me-2" style="color:var(--accent);"></i>{{ t.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="bi bi-plus-circle me-2"></i>New Support Request
|
<i class="bi bi-plus-circle me-2"></i>New Support Request
|
||||||
@@ -108,4 +133,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.template-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const title = btn.dataset.title;
|
||||||
|
const cat = btn.dataset.category;
|
||||||
|
const prio = btn.dataset.priority;
|
||||||
|
const desc = btn.dataset.description;
|
||||||
|
|
||||||
|
if (title) document.querySelector('input[name=title]').value = title;
|
||||||
|
if (desc) document.querySelector('textarea[name=description]').value = desc;
|
||||||
|
|
||||||
|
const catSel = document.querySelector('select[name=category]');
|
||||||
|
if (catSel) {
|
||||||
|
for (const opt of catSel.options) {
|
||||||
|
if (opt.value === cat) { opt.selected = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const prioSel = document.querySelector('select[name=priority]');
|
||||||
|
if (prioSel) {
|
||||||
|
for (const opt of prioSel.options) {
|
||||||
|
if (opt.value === prio) { opt.selected = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scroll to form
|
||||||
|
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
|
||||||
|
document.querySelector('input[name=title]').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -752,6 +752,32 @@ function buildCommentEl(c) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History -->
|
<!-- History -->
|
||||||
|
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
|
||||||
|
{% if ticket.satisfaction %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header"><i class="bi bi-star-half me-2"></i>Customer Satisfaction</div>
|
||||||
|
<div class="card-body" style="padding:16px 20px;">
|
||||||
|
{% if ticket.satisfaction.submitted %}
|
||||||
|
<div style="font-size:28px;color:#f59e0b;margin-bottom:6px;">
|
||||||
|
{% for i in range(ticket.satisfaction.rating) %}★{% endfor %}<span style="color:var(--border2);">{% for i in range(5 - ticket.satisfaction.rating) %}★{% endfor %}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
|
Rated {{ ticket.satisfaction.rating }}/5 on {{ ticket.satisfaction.submitted_at | localtime("%b %d, %Y") }}
|
||||||
|
</div>
|
||||||
|
{% if ticket.satisfaction.comment %}
|
||||||
|
<div style="margin-top:10px;font-size:13px;color:var(--text2);background:var(--surface2);border-radius:6px;padding:10px 12px;border:1px solid var(--border);">
|
||||||
|
"{{ ticket.satisfaction.comment }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:13px;color:var(--muted);">
|
||||||
|
<i class="bi bi-hourglass-split me-2"></i>Survey sent — awaiting response
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if history %}
|
{% if history %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
|
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>How did we do? — TechDesk</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:6px;}
|
||||||
|
.sub{font-size:13px;color:#64748b;margin-bottom:24px;}
|
||||||
|
.ticket-ref{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;margin-bottom:24px;font-size:13px;color:#334155;}
|
||||||
|
.ticket-ref strong{color:#2563eb;font-family:'Courier New',monospace;}
|
||||||
|
/* Star rating */
|
||||||
|
.stars{display:flex;flex-direction:row-reverse;justify-content:center;gap:6px;margin-bottom:28px;}
|
||||||
|
.stars input{display:none;}
|
||||||
|
.stars label{font-size:44px;color:#e2e8f0;cursor:pointer;transition:color .15s;line-height:1;}
|
||||||
|
.stars input:checked ~ label,
|
||||||
|
.stars label:hover,
|
||||||
|
.stars label:hover ~ label{color:#f59e0b;}
|
||||||
|
.stars input:checked + label{color:#f59e0b;}
|
||||||
|
textarea{width:100%;border:1px solid #e2e8f0;border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;resize:vertical;min-height:90px;color:#0f172a;transition:border-color .15s,box-shadow .15s;}
|
||||||
|
textarea:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.1);}
|
||||||
|
textarea::placeholder{color:#94a3b8;}
|
||||||
|
label.field-label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:#334155;text-transform:uppercase;margin-bottom:7px;}
|
||||||
|
.btn{width:100%;background:#2563eb;border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:16px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
|
||||||
|
.btn:hover{background:#1d4ed8;}
|
||||||
|
.alert{border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px;background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
||||||
|
.rating-hint{text-align:center;font-size:13px;color:#64748b;margin-bottom:4px;min-height:20px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<h1>⭐ How did we do?</h1>
|
||||||
|
<p class="sub">Your feedback helps us improve our IT support.</p>
|
||||||
|
|
||||||
|
<div class="ticket-ref">
|
||||||
|
<strong>{{ survey.ticket.ticket_number }}</strong> — {{ survey.ticket.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert"><i class="bi bi-exclamation-circle me-2"></i>{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label class="field-label" style="text-align:center;display:block;">Your Rating *</label>
|
||||||
|
<p class="rating-hint" id="rating-hint">Click a star to rate</p>
|
||||||
|
<div class="stars">
|
||||||
|
{% for i in [5,4,3,2,1] %}
|
||||||
|
<input type="radio" name="rating" id="star{{ i }}" value="{{ i }}"
|
||||||
|
{% if quick_rating == i %}checked{% endif %}
|
||||||
|
onchange="updateHint({{ i }})"/>
|
||||||
|
<label for="star{{ i }}" title="{{ i }} star{{ 's' if i > 1 }}" onmouseover="hintOver({{ i }})" onmouseout="hintOut()">★</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="field-label">Comment <span style="font-weight:400;text-transform:none;font-size:11px;color:#94a3b8;">(optional)</span></label>
|
||||||
|
<textarea name="comment" placeholder="Tell us what went well or how we could improve…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn"><i class="bi bi-send me-2"></i>Submit Feedback</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const hints = {1:'Very dissatisfied',2:'Dissatisfied',3:'Neutral',4:'Satisfied',5:'Very satisfied'};
|
||||||
|
function updateHint(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
|
||||||
|
function hintOver(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
|
||||||
|
function hintOut(){
|
||||||
|
const checked = document.querySelector('.stars input:checked');
|
||||||
|
document.getElementById('rating-hint').textContent = checked ? hints[checked.value] : 'Click a star to rate';
|
||||||
|
}
|
||||||
|
// Auto-submit when star clicked via quick_rating link
|
||||||
|
{% if quick_rating %}
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const el = document.getElementById('star{{ quick_rating }}');
|
||||||
|
if(el){ el.checked = true; updateHint({{ quick_rating }}); }
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Thank You — TechDesk</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
|
||||||
|
.icon{font-size:56px;margin-bottom:16px;}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
|
||||||
|
.sub{font-size:14px;color:#64748b;line-height:1.6;}
|
||||||
|
.stars{font-size:32px;color:#f59e0b;margin:20px 0 8px;}
|
||||||
|
.comment{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;font-size:13px;color:#334155;margin-top:12px;text-align:left;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">🙏</div>
|
||||||
|
<h1>Thank you for your feedback!</h1>
|
||||||
|
<p class="sub">Your response has been recorded and will help us improve our IT support.</p>
|
||||||
|
|
||||||
|
{% if survey.rating %}
|
||||||
|
<div class="stars">
|
||||||
|
{% for i in range(survey.rating) %}★{% endfor %}
|
||||||
|
<span style="color:#e2e8f0;">{% for i in range(5 - survey.rating) %}★{% endfor %}</span>
|
||||||
|
</div>
|
||||||
|
{% if survey.comment %}
|
||||||
|
<div class="comment">"{{ survey.comment }}"</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="sub" style="margin-top:20px;font-size:12px;color:#94a3b8;">
|
||||||
|
You can close this page. If the issue recurs, please submit a new ticket.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||||
|
<title>Survey Not Found — TechDesk</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
|
||||||
|
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
|
||||||
|
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
|
||||||
|
.sub{font-size:14px;color:#64748b;line-height:1.6;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:56px;margin-bottom:16px;">🔗</div>
|
||||||
|
<h1>This survey link is invalid</h1>
|
||||||
|
<p class="sub">The link may have already been used, or it does not exist. No further action is needed.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user