04/08 fixed survey email issue
This commit is contained in:
@@ -315,6 +315,7 @@ def _seed_settings():
|
|||||||
from app.models import SystemSetting
|
from app.models import SystemSetting
|
||||||
defaults = [
|
defaults = [
|
||||||
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
|
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
|
||||||
|
('survey_enabled', 'true', 'Send a satisfaction survey email when a ticket is resolved'),
|
||||||
('app_timezone', 'America/New_York', 'Display timezone for all dates and times in the UI'),
|
('app_timezone', 'America/New_York', 'Display timezone for all dates and times in the UI'),
|
||||||
('sla_critical_hours', '4', 'Hours before a CRITICAL ticket is considered overdue'),
|
('sla_critical_hours', '4', 'Hours before a CRITICAL ticket is considered overdue'),
|
||||||
('email_ingestion_enabled', '0', 'Enable automatic ticket creation from inbound email (1=on, 0=off)'),
|
('email_ingestion_enabled', '0', 'Enable automatic ticket creation from inbound email (1=on, 0=off)'),
|
||||||
|
|||||||
+20
-5
@@ -564,12 +564,27 @@ class TicketSatisfaction(db.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_for_ticket(cls, ticket):
|
def create_for_ticket(cls, ticket):
|
||||||
"""Create a survey row for a newly-resolved ticket.
|
"""Return a survey row ready for emailing.
|
||||||
Returns None if a survey already exists. Caller must commit.
|
|
||||||
|
Behaviour:
|
||||||
|
- No existing row → create and return a new one.
|
||||||
|
- Existing row, not yet submitted (rating is NULL) → return it so
|
||||||
|
the email can be re-sent. This covers the case where the row was
|
||||||
|
created but the email thread crashed before sending.
|
||||||
|
- Existing row, already submitted → return None (survey is complete).
|
||||||
|
|
||||||
|
Caller is responsible for committing the session.
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
if cls.query.filter_by(ticket_id=ticket.id).first():
|
existing = cls.query.filter_by(ticket_id=ticket.id).first()
|
||||||
return None
|
if existing:
|
||||||
|
if existing.submitted:
|
||||||
|
# Employee already rated — do not re-send
|
||||||
|
return None
|
||||||
|
# Row exists but email was never sent (prior crash) — re-use it
|
||||||
|
# Regenerate the token so any stale links in old emails are invalidated
|
||||||
|
existing.survey_token = secrets.token_urlsafe(32)
|
||||||
|
return existing
|
||||||
row = cls(
|
row = cls(
|
||||||
ticket_id = ticket.id,
|
ticket_id = ticket.id,
|
||||||
user_id = ticket.created_by_id,
|
user_id = ticket.created_by_id,
|
||||||
@@ -583,4 +598,4 @@ class TicketSatisfaction(db.Model):
|
|||||||
return self.rating is not None
|
return self.rating is not None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
|
return f'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
|
||||||
@@ -1183,6 +1183,19 @@ def settings():
|
|||||||
return redirect(url_for('admin.settings'))
|
return redirect(url_for('admin.settings'))
|
||||||
|
|
||||||
# ── Timezone setting ──────────────────────────────────────────────────
|
# ── Timezone setting ──────────────────────────────────────────────────
|
||||||
|
if form_type == 'survey':
|
||||||
|
new_value = '1' if request.form.get('survey_enabled') == '1' else '0'
|
||||||
|
old_value = SystemSetting.get('survey_enabled', '1')
|
||||||
|
SystemSetting.set('survey_enabled', new_value,
|
||||||
|
'Send satisfaction survey email when a ticket is resolved')
|
||||||
|
db.session.commit()
|
||||||
|
state_label = 'enabled' if new_value == '1' else 'disabled'
|
||||||
|
log_action(current_user.id, 'setting_update', 'system_setting', None,
|
||||||
|
f'survey_enabled changed from {old_value} to {new_value}')
|
||||||
|
logger.info(f'[ADMIN SETTINGS] survey_enabled={new_value} by admin_id={current_user.id}')
|
||||||
|
flash(f'Satisfaction survey has been {state_label}.', 'success')
|
||||||
|
return redirect(url_for('admin.settings'))
|
||||||
|
|
||||||
if form_type == 'timezone':
|
if form_type == 'timezone':
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
new_tz = request.form.get('app_timezone', 'America/New_York').strip()
|
new_tz = request.form.get('app_timezone', 'America/New_York').strip()
|
||||||
@@ -1299,6 +1312,7 @@ def settings():
|
|||||||
return redirect(url_for('admin.settings'))
|
return redirect(url_for('admin.settings'))
|
||||||
|
|
||||||
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
|
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
|
||||||
|
survey_enabled = SystemSetting.get_bool('survey_enabled', default=True)
|
||||||
email_settings = {
|
email_settings = {
|
||||||
'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False),
|
'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False),
|
||||||
'host' : SystemSetting.get('email_ingestion_host', ''),
|
'host' : SystemSetting.get('email_ingestion_host', ''),
|
||||||
@@ -1341,6 +1355,7 @@ def settings():
|
|||||||
]
|
]
|
||||||
return render_template('admin/settings.html',
|
return render_template('admin/settings.html',
|
||||||
registration_enabled=registration_enabled,
|
registration_enabled=registration_enabled,
|
||||||
|
survey_enabled=survey_enabled,
|
||||||
branding_settings=branding_settings,
|
branding_settings=branding_settings,
|
||||||
email_settings=email_settings,
|
email_settings=email_settings,
|
||||||
current_tz=current_tz,
|
current_tz=current_tz,
|
||||||
|
|||||||
@@ -327,3 +327,4 @@ def serve_logo(filename):
|
|||||||
abort(400)
|
abort(400)
|
||||||
upload_dir = current_app.config['UPLOAD_FOLDER']
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
return send_from_directory(upload_dir, filename, as_attachment=False)
|
return send_from_directory(upload_dir, filename, as_attachment=False)
|
||||||
|
|
||||||
|
|||||||
@@ -319,102 +319,155 @@ def notify_assignment(ticket, assigned_by):
|
|||||||
def send_satisfaction_survey(ticket):
|
def send_satisfaction_survey(ticket):
|
||||||
"""Send a satisfaction survey email when a ticket is resolved.
|
"""Send a satisfaction survey email when a ticket is resolved.
|
||||||
|
|
||||||
Creates a TicketSatisfaction row with a unique survey token, then
|
Every step is wrapped in explicit error handling and logged so failures
|
||||||
emails the ticket creator a link to rate their experience (1-5 stars).
|
are visible in the application log rather than swallowed silently.
|
||||||
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.models import TicketSatisfaction
|
||||||
from app import mail
|
from app import mail
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
|
|
||||||
creator = ticket.creator
|
logger.info(
|
||||||
if not creator or not creator.email_notif:
|
f'[SURVEY] send_satisfaction_survey called: '
|
||||||
|
f'ticket_id={ticket.id} number={ticket.ticket_number}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guard: survey feature must be enabled in system settings
|
||||||
|
from app.models import SystemSetting
|
||||||
|
if not SystemSetting.get_bool('survey_enabled', default=True):
|
||||||
|
logger.info('[SURVEY] Satisfaction survey is disabled in settings — skipping')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Guard: creator must exist and have email notifications enabled
|
||||||
|
creator = ticket.creator
|
||||||
|
if not creator:
|
||||||
|
logger.warning(f'[SURVEY] No creator found for ticket_id={ticket.id} — skipping')
|
||||||
|
return
|
||||||
|
if not creator.email_notif:
|
||||||
|
logger.info(
|
||||||
|
f'[SURVEY] Creator user_id={creator.id} has email_notif=False — skipping'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f'[SURVEY] Creator OK: user_id={creator.id} '
|
||||||
|
f'email={creator.email} email_notif={creator.email_notif}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the TicketSatisfaction row
|
||||||
|
try:
|
||||||
|
survey = TicketSatisfaction.create_for_ticket(ticket)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f'[SURVEY] create_for_ticket raised an exception for '
|
||||||
|
f'ticket_id={ticket.id}: {exc}', exc_info=True
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
survey = TicketSatisfaction.create_for_ticket(ticket)
|
|
||||||
if not survey:
|
if not survey:
|
||||||
return # already sent for this ticket
|
logger.info(
|
||||||
|
f'[SURVEY] Survey already exists for ticket_id={ticket.id} — skipping'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
db.session.commit()
|
try:
|
||||||
logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}…')
|
db.session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f'[SURVEY] db.session.commit() failed for ticket_id={ticket.id}: {exc}',
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
db.session.rollback()
|
||||||
|
return
|
||||||
|
|
||||||
# Build absolute URLs using APP_BASE_URL — identical to every other
|
logger.info(
|
||||||
# notification function in this file. url_for(_external=True) requires
|
f'[SURVEY CREATED] ticket_id={ticket.id} '
|
||||||
# an active request context which is not guaranteed here.
|
f'survey_id={survey.id} token={survey.survey_token[:8]}...'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build absolute URLs using APP_BASE_URL — no url_for() which requires
|
||||||
|
# an active request context not guaranteed in all calling paths
|
||||||
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
if not base_url:
|
||||||
|
logger.warning(
|
||||||
|
'[SURVEY] APP_BASE_URL is not set — survey links will be broken. '
|
||||||
|
'Set APP_BASE_URL in your .env (e.g. https://tickets.ltservicesinc.com)'
|
||||||
|
)
|
||||||
survey_url = f"{base_url}/survey/{survey.survey_token}"
|
survey_url = f"{base_url}/survey/{survey.survey_token}"
|
||||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||||
|
|
||||||
|
logger.info(f'[SURVEY] survey_url={survey_url}')
|
||||||
|
|
||||||
stars_html = ''.join(
|
stars_html = ''.join(
|
||||||
f'<a href="{survey_url}?rating={i}" '
|
f'<a href="{survey_url}?rating={i}" '
|
||||||
f'style="display:inline-block;margin:0 6px;font-size:38px;'
|
f'style="display:inline-block;margin:0 4px;font-size:40px;'
|
||||||
f'text-decoration:none;color:#f59e0b;" title="{i} star">★</a>'
|
f'text-decoration:none;color:#f59e0b;" title="{i} star">★</a>'
|
||||||
for i in range(1, 6)
|
for i in range(1, 6)
|
||||||
)
|
)
|
||||||
|
|
||||||
html = f"""
|
html = (
|
||||||
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
'<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;
|
'<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);">
|
'overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">'
|
||||||
<div style="background:#1e293b;padding:24px 32px;">
|
'<div style="background:#1e293b;padding:24px 32px;">'
|
||||||
<h1 style="color:#fff;margin:0;font-size:20px;">⭐ How did we do?</h1>
|
'<h1 style="color:#fff;margin:0;font-size:20px;">⭐ How did we do?</h1>'
|
||||||
</div>
|
'</div>'
|
||||||
<div style="padding:32px;">
|
'<div style="padding:32px;">'
|
||||||
<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>
|
f'<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>'
|
||||||
<p style="color:#334155;">
|
f'<p style="color:#334155;">Your ticket <strong>{ticket.ticket_number}</strong>'
|
||||||
Your ticket <strong>{ticket.ticket_number}</strong> —
|
f' has been marked as resolved. We'd love to hear how we did!</p>'
|
||||||
<em>{ticket.title}</em> — has been marked as resolved.
|
'<p style="color:#334155;font-weight:600;margin-bottom:6px;">'
|
||||||
We'd love to hear how we did!
|
'How satisfied were you with the resolution?</p>'
|
||||||
</p>
|
f'<p style="text-align:center;margin:24px 0;line-height:1;">{stars_html}</p>'
|
||||||
<p style="color:#334155;font-weight:600;margin-bottom:6px;">
|
f'<p style="text-align:center;margin-bottom:16px;">'
|
||||||
Click a star to rate your experience:
|
f'<a href="{survey_url}" style="color:#2563eb;font-size:13px;">'
|
||||||
</p>
|
'Or leave a written comment</a></p>'
|
||||||
<p style="text-align:center;margin:20px 0;line-height:1;">
|
f'<p style="color:#94a3b8;font-size:12px;">If the issue is not resolved, '
|
||||||
{stars_html}
|
f'<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>.</p>'
|
||||||
</p>
|
'</div>'
|
||||||
<p style="text-align:center;">
|
'<div style="background:#f8fafc;padding:16px 32px;text-align:center;'
|
||||||
<a href="{survey_url}" style="color:#2563eb;font-size:13px;">
|
'color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">'
|
||||||
Or leave a detailed comment
|
'TechDesk IT Helpdesk • This is an automated message.'
|
||||||
</a>
|
'</div></div></body></html>'
|
||||||
</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(
|
# Capture locals needed inside the thread before spawning.
|
||||||
subject = f'[TechDesk] How did we do? — {ticket.ticket_number}',
|
# Mirrors the pattern in send_email() exactly:
|
||||||
recipients = [creator.email],
|
# - current_app._get_current_object() resolves the proxy to the real
|
||||||
html = html,
|
# Flask app object so it is safe to reference inside a daemon thread
|
||||||
|
# that has no active context of its own yet.
|
||||||
|
# - The Message is built INSIDE the thread, within app.app_context(),
|
||||||
|
# not before — flask_mail reads app config at construction time.
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
ticket_num = ticket.ticket_number
|
||||||
|
ticket_id = ticket.id
|
||||||
|
creator_name = creator.full_name
|
||||||
|
creator_email= creator.email
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f'[SURVEY] Spawning email thread: recipient={creator_email} '
|
||||||
|
f'ticket_id={ticket_id}'
|
||||||
)
|
)
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
with current_app.app_context():
|
with app.app_context():
|
||||||
try:
|
try:
|
||||||
|
msg = Message(
|
||||||
|
subject = f'[TechDesk] How did we do? — {ticket_num}',
|
||||||
|
recipients = [creator_email],
|
||||||
|
html = html,
|
||||||
|
)
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
logger.info(f'[SURVEY EMAIL SENT] ticket_id={ticket.id} user_id={creator.id}')
|
logger.info(
|
||||||
|
f'[SURVEY EMAIL SENT] ticket_id={ticket_id} '
|
||||||
|
f'recipient={creator_email}'
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f'[SURVEY EMAIL FAILED] ticket_id={ticket.id} {exc}')
|
logger.error(
|
||||||
|
f'[SURVEY EMAIL FAILED] ticket_id={ticket_id} '
|
||||||
|
f'recipient={creator_email}: {exc}', exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
Thread(target=_send, daemon=True).start()
|
t = Thread(target=_send, daemon=True)
|
||||||
|
t.start()
|
||||||
|
logger.info(f'[SURVEY] Email thread started for ticket_id={ticket_id}')
|
||||||
|
|||||||
@@ -244,6 +244,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Satisfaction Survey -->
|
||||||
|
<div class="card mb-4" style="max-width:680px;">
|
||||||
|
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||||
|
<h5 class="mb-0" style="font-size:15px;font-weight:600;">
|
||||||
|
<i class="bi bi-star-half me-2"></i>Satisfaction Survey
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="padding:20px;">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
|
||||||
|
When enabled, employees automatically receive a one-click satisfaction survey email
|
||||||
|
whenever their ticket is marked as Resolved. Responses appear in the
|
||||||
|
<a href="{{ url_for('admin.satisfaction_report') }}" style="color:var(--accent3);">Satisfaction Report</a>.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ url_for('admin.settings') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="form_type" value="survey">
|
||||||
|
<div class="d-flex align-items-center justify-content-between p-3"
|
||||||
|
style="border:1px solid var(--border);border-radius:10px;background:var(--surface);">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:14px;">
|
||||||
|
<i class="bi bi-envelope-heart me-2"></i>Send Survey on Resolve
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
|
||||||
|
Automatically email employees a 1–5 star rating prompt when their ticket is resolved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3" style="flex-shrink:0;margin-left:16px;">
|
||||||
|
<span class="badge {% if survey_enabled %}bg-success{% else %}bg-secondary{% endif %}"
|
||||||
|
style="font-size:11px;padding:5px 10px;">
|
||||||
|
{{ 'Enabled' if survey_enabled else 'Disabled' }}
|
||||||
|
</span>
|
||||||
|
{% if survey_enabled %}
|
||||||
|
<button type="submit" name="survey_enabled" value="0"
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="return confirm('Disable satisfaction surveys? No survey emails will be sent when tickets are resolved.')">
|
||||||
|
<i class="bi bi-toggle-on me-1"></i>Disable
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="survey_enabled" value="1"
|
||||||
|
class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-toggle-off me-1"></i>Enable
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Registration -->
|
<!-- Registration -->
|
||||||
<div class="card" style="max-width:680px;">
|
<div class="card" style="max-width:680px;">
|
||||||
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
|
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||||
|
|||||||
Reference in New Issue
Block a user