04/08 fixed survey email issue

This commit is contained in:
2026-04-08 10:38:50 -04:00
parent 60db5c3f9a
commit cc81e0cca3
6 changed files with 210 additions and 76 deletions
+1
View File
@@ -315,6 +315,7 @@ def _seed_settings():
from app.models import SystemSetting
defaults = [
('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'),
('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)'),
+18 -3
View File
@@ -564,12 +564,27 @@ class TicketSatisfaction(db.Model):
@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.
"""Return a survey row ready for emailing.
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
if cls.query.filter_by(ticket_id=ticket.id).first():
existing = cls.query.filter_by(ticket_id=ticket.id).first()
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(
ticket_id = ticket.id,
user_id = ticket.created_by_id,
+15
View File
@@ -1183,6 +1183,19 @@ def settings():
return redirect(url_for('admin.settings'))
# ── 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':
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
new_tz = request.form.get('app_timezone', 'America/New_York').strip()
@@ -1299,6 +1312,7 @@ def settings():
return redirect(url_for('admin.settings'))
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
survey_enabled = SystemSetting.get_bool('survey_enabled', default=True)
email_settings = {
'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False),
'host' : SystemSetting.get('email_ingestion_host', ''),
@@ -1341,6 +1355,7 @@ def settings():
]
return render_template('admin/settings.html',
registration_enabled=registration_enabled,
survey_enabled=survey_enabled,
branding_settings=branding_settings,
email_settings=email_settings,
current_tz=current_tz,
+1
View File
@@ -327,3 +327,4 @@ def serve_logo(filename):
abort(400)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, filename, as_attachment=False)
+121 -68
View File
@@ -319,102 +319,155 @@ def notify_assignment(ticket, assigned_by):
def send_satisfaction_survey(ticket):
"""Send a satisfaction survey email when a ticket is resolved.
Creates a TicketSatisfaction row with a unique survey token, then
emails the ticket creator a link to rate their experience (1-5 stars).
The link is token-authenticated so the employee does not need to be
logged in to respond.
URL construction
----------------
All other notification functions in this module use APP_BASE_URL from
config to build absolute URLs NOT url_for(..., _external=True).
This function follows the same pattern. Using url_for inside an f-string
that is evaluated before the background thread starts causes a
RuntimeError ("Working outside of request context") which silently
swallows the entire function before the Thread is ever created.
Called from update_ticket() after commit, when status Resolved.
Every step is wrapped in explicit error handling and logged so failures
are visible in the application log rather than swallowed silently.
"""
from app.models import TicketSatisfaction
from app import mail
from threading import Thread
from flask_mail import Message
creator = ticket.creator
if not creator or not creator.email_notif:
logger.info(
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
if not survey:
return # already sent for this ticket
logger.info(
f'[SURVEY] Survey already exists for ticket_id={ticket.id} — skipping'
)
return
try:
db.session.commit()
logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}')
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
# notification function in this file. url_for(_external=True) requires
# an active request context which is not guaranteed here.
logger.info(
f'[SURVEY CREATED] ticket_id={ticket.id} '
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('/')
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}"
ticket_url = f"{base_url}/tickets/{ticket.id}"
logger.info(f'[SURVEY] survey_url={survey_url}')
stars_html = ''.join(
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">&#9733;</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;">&#11088; 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> &mdash;
<em>{ticket.title}</em> &mdash; 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 &bull; This is an automated message.
</div>
</div>
</body></html>"""
html = (
'<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;">&#11088; How did we do?</h1>'
'</div>'
'<div style="padding:32px;">'
f'<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>'
f'<p style="color:#334155;">Your ticket <strong>{ticket.ticket_number}</strong>'
f' has been marked as resolved. We&#39;d love to hear how we did!</p>'
'<p style="color:#334155;font-weight:600;margin-bottom:6px;">'
'How satisfied were you with the resolution?</p>'
f'<p style="text-align:center;margin:24px 0;line-height:1;">{stars_html}</p>'
f'<p style="text-align:center;margin-bottom:16px;">'
f'<a href="{survey_url}" style="color:#2563eb;font-size:13px;">'
'Or leave a written comment</a></p>'
f'<p style="color:#94a3b8;font-size:12px;">If the issue is not resolved, '
f'<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>.</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 &bull; 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,
# Capture locals needed inside the thread before spawning.
# Mirrors the pattern in send_email() exactly:
# - current_app._get_current_object() resolves the proxy to the real
# 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():
with current_app.app_context():
with app.app_context():
try:
msg = Message(
subject = f'[TechDesk] How did we do? — {ticket_num}',
recipients = [creator_email],
html = html,
)
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:
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}')
+49
View File
@@ -244,6 +244,55 @@
</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 15 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 -->
<div class="card" style="max-width:680px;">
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">