04/07 updated timezone, report via email, etc

This commit is contained in:
2026-04-07 11:24:08 -04:00
parent 56c65bfe4c
commit fed51f8c28
14 changed files with 1022 additions and 29 deletions
+3 -3
View File
@@ -17,9 +17,9 @@ MAIL_SERVER=mail.ltservicesinc.com
MAIL_PORT=465
MAIL_USE_TLS=False
MAIL_USE_SSL=True
MAIL_USERNAME=it.helpdesk@ltservicesinc.com
MAIL_PASSWORD=IT*H3lpD35k!
MAIL_DEFAULT_SENDER=IT Helpdesk <it.helpdesk@ltservicesinc.com>
MAIL_USERNAME=donotreply@ltservicesinc.com
MAIL_PASSWORD=M-6ZW+omp7n]
MAIL_DEFAULT_SENDER=IT Helpdesk <donotreply@ltservicesinc.com>
# IT Department Email (receives all new ticket notifications)
IT_DEPT_EMAIL=da.nguyen8744@gmail.com
+87 -1
View File
@@ -117,6 +117,33 @@ def create_app(config_name=None):
# object is already in session), then fall back to a SELECT by PK.
return db.session.get(User, int(user_id))
# ── Timezone filter ──────────────────────────────────────────────────────
# All datetimes in the DB are stored as UTC (naive). The localtime filter
# converts them to the admin-configured display timezone for templates.
# Python routes should call app_localtime(dt) when they need a local datetime.
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _get_tz(app_obj):
"""Return the configured ZoneInfo, falling back to UTC on bad input."""
from app.models import SystemSetting
tz_name = SystemSetting.get('app_timezone', 'America/New_York') or 'America/New_York'
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, KeyError):
app_obj.logger.warning(f'[TZ] Unknown timezone {tz_name!r}, falling back to UTC')
return ZoneInfo('UTC')
def localtime_filter(dt, fmt='%b %d, %Y %H:%M %Z'):
"""Jinja2 filter: convert a naive UTC datetime to local display time."""
if dt is None:
return ''
from datetime import timezone as _tz
tz = _get_tz(app)
aware = dt.replace(tzinfo=_tz.utc)
return aware.astimezone(tz).strftime(fmt)
app.jinja_env.filters['localtime'] = localtime_filter
# ── Context processors ────────────────────────────────────────────────────
@app.context_processor
def inject_globals():
@@ -136,7 +163,17 @@ def create_app(config_name=None):
'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'),
}
return dict(unread_notifications=unread, branding=branding)
from datetime import datetime as _dt, timezone as _tz
from zoneinfo import ZoneInfo as _ZI
from app.models import SystemSetting as _SS
_tz_name = _SS.get('app_timezone', 'America/New_York') or 'America/New_York'
try:
_zone = _ZI(_tz_name)
except Exception:
_zone = _ZI('UTC')
now_local = _dt.now(_tz.utc).astimezone(_zone)
return dict(unread_notifications=unread, branding=branding,
now_local=now_local, app_tz_name=_tz_name)
# ── DB initialisation (first run) ─────────────────────────────────────────
with app.app_context():
@@ -144,6 +181,9 @@ def create_app(config_name=None):
_seed_admin(app)
_seed_settings()
# ── Email ingestion background scheduler ────────────────────────────────────
_start_email_ingestion_scheduler(app)
# ── SLA background scheduler ──────────────────────────────────────────────
# APScheduler runs inside the gunicorn worker process (single-worker
# eventlet setup), so no cross-process coordination is needed.
@@ -188,12 +228,58 @@ def _start_sla_scheduler(app):
app.logger.error(f'[SLA] Failed to start APScheduler: {exc}')
def _start_email_ingestion_scheduler(app):
"""Start the APScheduler job that polls the inbound mailbox for new emails.
The interval is read from SystemSetting at job creation time (default 5 min).
The job is a no-op when email_ingestion_enabled = '0', so it is safe to
always register it — no credentials are required until the admin enables it.
"""
import os as _os
if _os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
return # skip reloader child process
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.services.email_ingestion_service import check_inbound_email
from app.models import SystemSetting
with app.app_context():
interval = int(SystemSetting.get('email_ingestion_interval', '5') or '5')
scheduler = BackgroundScheduler(daemon=True)
scheduler.add_job(
func = check_inbound_email,
trigger = IntervalTrigger(minutes=interval),
id = 'email_ingest',
name = 'Inbound Email Ingestion',
replace_existing = True,
args = [app],
)
scheduler.start()
app.logger.info(
f'[EMAIL INGEST] APScheduler started — polling every {interval} minute(s)'
)
except Exception as exc:
app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}')
def _seed_settings():
"""Ensure all required system settings exist with safe defaults."""
from app.models import SystemSetting
defaults = [
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
('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)'),
('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'),
('email_ingestion_port', '993', 'IMAP SSL port'),
('email_ingestion_user', '', 'Mailbox username / email address'),
('email_ingestion_password', '', 'Mailbox password (stored in plaintext — use a dedicated app password)'),
('email_ingestion_folder', 'INBOX', 'IMAP folder to watch for new mail'),
('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'),
('email_ingestion_interval', '5', 'Poll interval in minutes'),
('sla_high_hours', '8', 'Hours before a HIGH ticket is considered overdue'),
('sla_medium_hours', '48', 'Hours before a MEDIUM ticket is considered overdue'),
('sla_low_hours', '120', 'Hours before a LOW ticket is considered overdue'),
+34
View File
@@ -419,3 +419,37 @@ class CannedResponse(db.Model):
def __repr__(self):
return f'<CannedResponse {self.title}>'
class KBFeedback(db.Model):
"""One thumbs-up or thumbs-down vote per user per KB article.
The unique constraint ensures each user can only vote once per article.
Updating a vote replaces the existing row via the route logic (upsert).
Votes are stored anonymously in aggregate on the KnowledgeBase table via
two counters (helpful_count, not_helpful_count) for fast display in the
admin list without joining this table on every page load.
"""
__tablename__ = 'kb_feedback'
id = db.Column(db.Integer, primary_key=True)
article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id',
ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
ondelete='CASCADE'), nullable=False)
is_helpful = db.Column(db.Boolean, nullable=False) # True=👍 False=👎
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow)
article = db.relationship('KnowledgeBase',
backref=db.backref('feedback', lazy='dynamic',
cascade='all, delete-orphan'))
user = db.relationship('User', foreign_keys=[user_id])
__table_args__ = (
db.UniqueConstraint('article_id', 'user_id', name='uq_kb_feedback'),
)
def __repr__(self):
return f'<KBFeedback article={self.article_id} user={self.user_id} helpful={self.is_helpful}>'
+192 -2
View File
@@ -12,7 +12,7 @@ import bleach
from app import db, limiter
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus, CannedResponse)
from app.services.log_service import log_action
from app.services.log_service import log_action, log_ticket_history
from app.services.validation_service import validate_password, validate_file
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -930,6 +930,109 @@ def canned_response_delete(item_id):
return redirect(url_for('admin.canned_responses'))
# ─── Bulk Ticket Actions ─────────────────────────────────────────────────────
@admin_bp.route('/tickets/bulk-action', methods=['POST'])
@login_required
@it_required
def bulk_ticket_action():
"""Apply a single action to multiple selected tickets at once.
Accepted actions:
- resolve → set status to Resolved, stamp resolved_at
- close → set status to Closed, stamp closed_at
- assign_me → assign all selected tickets to current user
- unassign → clear assigned_to_id
"""
from app.services.notification_service import notify_status_change
from app.services.sla_service import clear_sla_notification
action = request.form.get('action', '')
ticket_ids = request.form.getlist('ticket_ids', type=int)
if not ticket_ids:
flash('No tickets selected.', 'warning')
return redirect(url_for('admin.all_tickets'))
valid_actions = ('resolve', 'close', 'assign_me', 'unassign')
if action not in valid_actions:
flash('Invalid action.', 'danger')
return redirect(url_for('admin.all_tickets'))
tickets = Ticket.query.filter(Ticket.id.in_(ticket_ids)).all()
now = datetime.utcnow()
count = 0
notif_tickets = [] # collect for post-commit notifications
for ticket in tickets:
old_status = ticket.status
old_assigned = ticket.assigned_to_id
changed = False
if action == 'resolve' and ticket.status not in (
TicketStatus.RESOLVED, TicketStatus.CLOSED):
ticket.status = TicketStatus.RESOLVED
ticket.resolved_at = now
clear_sla_notification(ticket.id)
log_ticket_history(ticket, 'status', old_status,
TicketStatus.RESOLVED, current_user.id)
notif_tickets.append((ticket, old_status))
changed = True
elif action == 'close' and ticket.status != TicketStatus.CLOSED:
ticket.status = TicketStatus.CLOSED
ticket.closed_at = now
clear_sla_notification(ticket.id)
log_ticket_history(ticket, 'status', old_status,
TicketStatus.CLOSED, current_user.id)
notif_tickets.append((ticket, old_status))
changed = True
elif action == 'assign_me' and ticket.assigned_to_id != current_user.id:
ticket.assigned_to_id = current_user.id
log_ticket_history(ticket, 'assigned_to',
old_assigned or 'Unassigned',
current_user.full_name, current_user.id)
changed = True
elif action == 'unassign' and ticket.assigned_to_id is not None:
ticket.assigned_to_id = None
log_ticket_history(ticket, 'assigned_to',
old_assigned or 'Unassigned',
'Unassigned', current_user.id)
changed = True
if changed:
log_action(current_user.id, f'ticket_bulk_{action}', 'ticket',
ticket.id, f'action={action}')
count += 1
db.session.commit()
logger.info(f'[BULK ACTION] action={action} affected={count} '
f'ticket_ids={ticket_ids} by user_id={current_user.id}')
# Send status-change notifications after commit
for ticket, old_status in notif_tickets:
notify_status_change(ticket, old_status, current_user)
action_labels = {
'resolve': 'resolved',
'close': 'closed',
'assign_me': 'assigned to you',
'unassign': 'unassigned',
}
flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success')
# Preserve current filter params on redirect
return redirect(url_for('admin.all_tickets',
status = request.form.get('status', ''),
priority = request.form.get('priority', ''),
assigned = request.form.get('assigned', ''),
q = request.form.get('q', ''),
page = request.form.get('page', 1),
))
def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
@@ -957,6 +1060,58 @@ def settings():
flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings'))
# ── Timezone setting ──────────────────────────────────────────────────
if form_type == 'timezone':
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
new_tz = request.form.get('app_timezone', 'America/New_York').strip()
try:
ZoneInfo(new_tz) # validate before saving
except (ZoneInfoNotFoundError, KeyError):
flash(f'Unknown timezone "{new_tz}". Please select a valid timezone.', 'danger')
return redirect(url_for('admin.settings'))
old_tz = SystemSetting.get('app_timezone', 'America/New_York')
SystemSetting.set('app_timezone', new_tz, 'Display timezone for all dates and times in the UI')
db.session.commit()
log_action(current_user.id, 'setting_update', 'system_setting', None,
f'app_timezone changed from {old_tz!r} to {new_tz!r}')
logger.info(f'[ADMIN SETTINGS] app_timezone={new_tz} by admin_id={current_user.id}')
flash(f'Timezone updated to {new_tz}.', 'success')
return redirect(url_for('admin.settings'))
# ── Email ingestion settings ──────────────────────────────────────────
if form_type == 'email_ingestion':
fields = {
'email_ingestion_enabled' : request.form.get('email_ingestion_enabled', '0'),
'email_ingestion_host' : request.form.get('email_ingestion_host', '').strip(),
'email_ingestion_port' : request.form.get('email_ingestion_port', '993').strip(),
'email_ingestion_user' : request.form.get('email_ingestion_user', '').strip(),
'email_ingestion_folder' : request.form.get('email_ingestion_folder', 'INBOX').strip(),
'email_ingestion_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(),
'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').strip(),
}
# Password: only update if a new value was provided
new_pw = request.form.get('email_ingestion_password', '').strip()
if new_pw:
fields['email_ingestion_password'] = new_pw
changes = []
for key, value in fields.items():
old_val = SystemSetting.get(key, '')
if str(value) != str(old_val):
SystemSetting.set(key, value)
safe_key = key.replace('email_ingestion_', '')
# Never log the password value
changes.append(f'{safe_key}={"[updated]" if "password" in key else repr(value)}')
db.session.commit()
if changes:
log_action(current_user.id, 'email_ingestion_settings_update',
'system_setting', None, ', '.join(changes))
logger.info(f'[ADMIN EMAIL INGEST] Settings updated: {", ".join(changes)} '
f'by admin_id={current_user.id}')
flash('Email ingestion settings saved.', 'success')
return redirect(url_for('admin.settings'))
# ── Branding update ───────────────────────────────────────────────────
if form_type == 'branding':
fields = {
@@ -1022,6 +1177,16 @@ def settings():
return redirect(url_for('admin.settings'))
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
email_settings = {
'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False),
'host' : SystemSetting.get('email_ingestion_host', ''),
'port' : SystemSetting.get('email_ingestion_port', '993'),
'user' : SystemSetting.get('email_ingestion_user', ''),
'password': SystemSetting.get('email_ingestion_password', ''),
'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'),
'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'),
'interval': SystemSetting.get('email_ingestion_interval', '5'),
}
branding_settings = {
'app_name' : SystemSetting.get('app_name', 'TechDesk'),
'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'),
@@ -1030,6 +1195,31 @@ def settings():
'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'),
}
current_tz = SystemSetting.get('app_timezone', 'America/New_York')
# Common timezone list for the selector
common_timezones = [
('America/New_York', 'Eastern Time (ET) — New York'),
('America/Chicago', 'Central Time (CT) — Chicago'),
('America/Denver', 'Mountain Time (MT) — Denver'),
('America/Phoenix', 'Mountain Time, no DST — Phoenix'),
('America/Los_Angeles', 'Pacific Time (PT) — Los Angeles'),
('America/Anchorage', 'Alaska Time — Anchorage'),
('Pacific/Honolulu', 'Hawaii Time — Honolulu'),
('America/Puerto_Rico', 'Atlantic Time — Puerto Rico'),
('UTC', 'UTC — Coordinated Universal Time'),
('Europe/London', 'GMT/BST — London'),
('Europe/Paris', 'CET/CEST — Paris, Berlin'),
('Europe/Helsinki', 'EET/EEST — Helsinki, Athens'),
('Asia/Dubai', 'GST — Dubai'),
('Asia/Kolkata', 'IST — India'),
('Asia/Singapore', 'SGT — Singapore'),
('Asia/Tokyo', 'JST — Tokyo'),
('Australia/Sydney', 'AEST/AEDT — Sydney'),
('Pacific/Auckland', 'NZST/NZDT — Auckland'),
]
return render_template('admin/settings.html',
registration_enabled=registration_enabled,
branding_settings=branding_settings)
branding_settings=branding_settings,
email_settings=email_settings,
current_tz=current_tz,
common_timezones=common_timezones)
+79 -2
View File
@@ -9,7 +9,8 @@ from werkzeug.utils import secure_filename
from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse)
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
KBFeedback)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
@@ -674,7 +675,19 @@ def kb_article(article_id):
db.session.commit()
# Re-fetch so the template receives the post-increment value.
db.session.refresh(article)
return render_template('tickets/kb_article.html', article=article)
# Load the current user's vote (if any) so the feedback widget shows state
user_feedback = KBFeedback.query.filter_by(
article_id=article.id, user_id=current_user.id
).first()
# Aggregate counts for the widget
helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count()
not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count()
return render_template('tickets/kb_article.html',
article=article,
user_feedback=user_feedback,
helpful_count=helpful_count,
not_helpful_count=not_helpful_count,
)
# ─── Ticket Link / Unlink ────────────────────────────────────────────────────
@@ -840,6 +853,70 @@ def get_canned_responses():
]})
# ─── KB Article Feedback ─────────────────────────────────────────────────────
@tickets_bp.route('/kb/<int:article_id>/feedback', methods=['POST'])
@login_required
def kb_feedback(article_id):
"""Record or update a thumbs-up / thumbs-down vote for a KB article.
One vote per user per article subsequent submissions update the existing
row. Submitting the same vote a second time toggles it off (removes it),
giving users an undo path.
"""
article = db.session.get(KnowledgeBase, article_id) or abort(404)
value = request.form.get('helpful') # '1' = helpful, '0' = not helpful
if value not in ('0', '1'):
abort(400)
is_helpful = value == '1'
existing = KBFeedback.query.filter_by(
article_id=article.id, user_id=current_user.id
).first()
if existing:
if existing.is_helpful == is_helpful:
# Same vote again → toggle off (remove)
db.session.delete(existing)
log_action(current_user.id, 'kb_feedback_remove', 'knowledge_base',
article.id, f'was_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK REMOVE] article_id={article.id} '
f'user_id={current_user.id} was_helpful={is_helpful}')
else:
# Changed vote → update
existing.is_helpful = is_helpful
log_action(current_user.id, 'kb_feedback_update', 'knowledge_base',
article.id, f'is_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK UPDATE] article_id={article.id} '
f'user_id={current_user.id} is_helpful={is_helpful}')
else:
fb = KBFeedback(
article_id = article.id,
user_id = current_user.id,
is_helpful = is_helpful,
)
db.session.add(fb)
log_action(current_user.id, 'kb_feedback_create', 'knowledge_base',
article.id, f'is_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK CREATE] article_id={article.id} '
f'user_id={current_user.id} is_helpful={is_helpful}')
db.session.commit()
# Return JSON so the widget can update without a full reload
helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count()
not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count()
from flask import jsonify
return jsonify({
'ok' : True,
'helpful_count' : helpful_count,
'not_helpful_count': not_helpful_count,
'user_vote' : None if (existing and existing.is_helpful == is_helpful)
else ('helpful' if is_helpful else 'not_helpful'),
})
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime':
+316
View File
@@ -0,0 +1,316 @@
"""
Email Ingestion Service convert inbound emails into tickets.
Architecture
------------
A scheduled APScheduler job (check_inbound_email) runs every N minutes,
connects to a configured IMAP mailbox, and converts unread messages into
tickets. The job is wired into create_app() alongside the SLA scheduler.
Sender resolution
-----------------
The From address is matched against existing User.email rows.
- Match found ticket is created under that user's account.
- No match found ticket is created under a configurable fallback user
(default: the system admin). A comment is prepended noting the external
sender so IT staff can follow up.
Duplicate suppression
---------------------
Message-IDs (from the Message-ID header) are stored in the SystemSetting
key email_ingested_message_ids as a comma-separated list (capped at 500
entries). Re-delivering an already-processed message is a no-op.
Configuration (all stored in SystemSetting, editable from admin/settings)
----------
email_ingestion_enabled '1' / '0'
email_ingestion_host IMAP server hostname
email_ingestion_port IMAP port (default 993)
email_ingestion_user Mailbox username / email address
email_ingestion_password Mailbox password
email_ingestion_interval Poll interval in minutes (default 5)
email_ingestion_folder IMAP folder to watch (default INBOX)
email_ingestion_move_to Folder to move processed mail into (default Processed)
"""
import email
import imaplib
import logging
import re
from datetime import datetime
from email.header import decode_header
from email.utils import parseaddr, getaddresses
logger = logging.getLogger(__name__)
_MAX_STORED_IDS = 500 # cap on the message-ID suppression list
# ── Helpers ───────────────────────────────────────────────────────────────────
def _decode_header_value(raw):
"""Decode an RFC-2047 encoded email header value to a plain string."""
if raw is None:
return ''
parts = []
for chunk, charset in decode_header(raw):
if isinstance(chunk, bytes):
try:
parts.append(chunk.decode(charset or 'utf-8', errors='replace'))
except (LookupError, UnicodeDecodeError):
parts.append(chunk.decode('utf-8', errors='replace'))
else:
parts.append(chunk)
return ''.join(parts).strip()
def _extract_plain_text(msg):
"""Walk a MIME message and return the first text/plain part, or a
stripped-down version of the first text/html part as a fallback."""
plain = None
html = None
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get('Content-Disposition', ''))
if 'attachment' in cd:
continue
if ct == 'text/plain' and plain is None:
try:
plain = part.get_payload(decode=True).decode(
part.get_content_charset() or 'utf-8', errors='replace'
)
except Exception:
pass
elif ct == 'text/html' and html is None:
try:
html = part.get_payload(decode=True).decode(
part.get_content_charset() or 'utf-8', errors='replace'
)
except Exception:
pass
else:
ct = msg.get_content_type()
try:
body = msg.get_payload(decode=True).decode(
msg.get_content_charset() or 'utf-8', errors='replace'
)
except Exception:
body = ''
if ct == 'text/plain':
plain = body
elif ct == 'text/html':
html = body
if plain:
return plain.strip()
if html:
# Minimal HTML → plain text strip
text = re.sub(r'<br\s*/?>', '\n', html, flags=re.IGNORECASE)
text = re.sub(r'<[^>]+>', '', text)
import html as html_module
return html_module.unescape(text).strip()
return ''
def _get_setting(key, default=''):
"""Read a SystemSetting value inside an existing app context."""
from app.models import SystemSetting
return SystemSetting.get(key, default)
def _load_seen_ids():
raw = _get_setting('email_ingested_message_ids', '')
return set(x.strip() for x in raw.split(',') if x.strip())
def _save_seen_ids(seen: set):
from app.models import SystemSetting
from app import db
# Keep the most recent N IDs to prevent unbounded growth
trimmed = sorted(seen)[-_MAX_STORED_IDS:]
SystemSetting.set(
'email_ingested_message_ids',
','.join(trimmed),
'Message-IDs of emails already converted to tickets',
)
db.session.commit()
# ── Core ingestion logic ──────────────────────────────────────────────────────
def check_inbound_email(app):
"""Entry point called by APScheduler. Wraps _run_ingestion with
error isolation so a transient IMAP failure never kills the worker."""
with app.app_context():
try:
if _get_setting('email_ingestion_enabled', '0') != '1':
return
_run_ingestion(app)
except Exception as exc:
logger.error(f'[EMAIL INGEST] Unhandled error: {exc}', exc_info=True)
def _run_ingestion(app):
from app import db
from app.models import (Ticket, TicketStatus, TicketPriority,
TicketCategory, User, UserRole)
from app.services.notification_service import notify_new_ticket
from app.services.sla_service import set_due_date
host = _get_setting('email_ingestion_host', '')
port = int(_get_setting('email_ingestion_port', '993'))
username = _get_setting('email_ingestion_user', '')
password = _get_setting('email_ingestion_password', '')
folder = _get_setting('email_ingestion_folder', 'INBOX')
move_to = _get_setting('email_ingestion_move_to', 'Processed')
if not host or not username or not password:
logger.warning('[EMAIL INGEST] Missing IMAP credentials — skipping')
return
seen_ids = _load_seen_ids()
new_ids = set()
created = 0
try:
imap = imaplib.IMAP4_SSL(host, port)
imap.login(username, password)
except Exception as exc:
logger.error(f'[EMAIL INGEST] IMAP login failed: {exc}')
return
try:
imap.select(folder)
# Search for unseen messages only
status, data = imap.search(None, 'UNSEEN')
if status != 'OK' or not data[0]:
return
msg_ids = data[0].split()
logger.info(f'[EMAIL INGEST] Found {len(msg_ids)} unseen message(s) in {folder}')
for num in msg_ids:
try:
_, raw = imap.fetch(num, '(RFC822)')
msg = email.message_from_bytes(raw[0][1])
message_id = msg.get('Message-ID', '').strip()
if message_id and message_id in seen_ids:
logger.debug(f'[EMAIL INGEST] Skipping duplicate {message_id}')
continue
# ── Parse headers ─────────────────────────────────────────────
subject = _decode_header_value(msg.get('Subject', '(No Subject)'))
from_raw = msg.get('From', '')
from_name, from_email = parseaddr(from_raw)
from_email = from_email.lower().strip()
body = _extract_plain_text(msg)
if not body:
body = f'[Email received from {from_email} with no readable body]'
# Truncate very long bodies to 8000 chars
if len(body) > 8000:
body = body[:8000] + '\n\n[…message truncated…]'
# ── Resolve sender to a user ──────────────────────────────────
sender_user = User.query.filter_by(
email=from_email, is_active=True
).first()
if sender_user:
created_by_id = sender_user.id
external_note = None
else:
# Fall back to the first active admin
fallback = User.query.filter(
User.role == UserRole.ADMIN,
User.is_active == True,
).first()
if not fallback:
logger.warning(
f'[EMAIL INGEST] No fallback admin found, skipping: {from_email}'
)
continue
created_by_id = fallback.id
external_note = (
f'**[Email received from unknown sender]**\n\n'
f'From: {from_name} <{from_email}>\n\n'
f'This ticket was automatically created from an inbound email. '
f'The sender is not a registered user — please follow up directly.'
)
# ── Create the ticket ─────────────────────────────────────────
ticket = Ticket(
title = subject[:200],
description = body,
category = TicketCategory.OTHER,
priority = TicketPriority.MEDIUM,
status = TicketStatus.OPEN,
created_by_id = created_by_id,
ai_generated = False,
)
ticket.ticket_number = ticket.generate_ticket_number()
set_due_date(ticket, app)
db.session.add(ticket)
db.session.flush()
# Prepend external-sender note as a comment if needed
if external_note:
from app.models import Comment
from app.services.validation_service import render_comment_body
comment = Comment(
ticket_id = ticket.id,
author_id = created_by_id,
body = render_comment_body(external_note),
is_internal= True, # IT staff only
)
db.session.add(comment)
from app.services.log_service import log_action
log_action(
created_by_id, 'ticket_create_email', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} '
f'from_email={from_email} message_id={message_id or "none"}'
)
db.session.commit()
logger.info(
f'[EMAIL INGEST] Created ticket {ticket.ticket_number} '
f'from={from_email} subject="{subject[:60]}"'
)
notify_new_ticket(ticket)
# Track message ID for deduplication
if message_id:
new_ids.add(message_id)
created += 1
# Move processed message to done folder
try:
imap.create(move_to)
except Exception:
pass # folder may already exist
imap.copy(num, move_to)
imap.store(num, '+FLAGS', '\\Deleted')
except Exception as exc:
db.session.rollback()
logger.error(f'[EMAIL INGEST] Failed to process message {num}: {exc}',
exc_info=True)
imap.expunge()
finally:
try:
imap.logout()
except Exception:
pass
if new_ids:
_save_seen_ids(seen_ids | new_ids)
if created:
logger.info(f'[EMAIL INGEST] Run complete — {created} ticket(s) created')
+8 -1
View File
@@ -65,7 +65,7 @@
{% if articles %}
<table class="table mb-0">
<thead>
<tr><th>Title</th><th>Category</th><th>Author</th><th>Published</th><th>Views</th><th>Updated</th><th>Actions</th></tr>
<tr><th>Title</th><th>Category</th><th>Author</th><th>Published</th><th>Views</th><th style="white-space:nowrap;">👍 / 👎</th><th>Updated</th><th>Actions</th></tr>
</thead>
<tbody>
{% for art in articles %}
@@ -81,6 +81,13 @@
{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</td>
<td style="font-size:12px;white-space:nowrap;">
{% set hc = art.feedback.filter_by(is_helpful=True).count() %}
{% set nc = art.feedback.filter_by(is_helpful=False).count() %}
<span style="color:var(--success);">👍 {{ hc }}</span>
<span style="color:var(--muted);margin:0 3px;">/</span>
<span style="color:var(--danger);">👎 {{ nc }}</span>
</td>
<td style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td>
<td>
<div class="d-flex gap-1 align-items-center">
+131
View File
@@ -18,6 +18,45 @@
{% endfor %}
{% endwith %}
<!-- Timezone -->
<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-clock me-2"></i>Date &amp; Time
</h5>
</div>
<div class="card-body" style="padding:20px;">
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
All timestamps are stored in UTC internally. This setting controls how they are
displayed throughout the application — tickets, comments, history, and logs.
</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="timezone">
<div class="mb-3">
<label class="form-label">Display Timezone</label>
<select class="form-select" name="app_timezone" style="max-width:400px;">
{% for tz_value, tz_label in common_timezones %}
<option value="{{ tz_value }}" {% if tz_value == current_tz %}selected{% endif %}>
{{ tz_label }}
</option>
{% endfor %}
{% if current_tz not in common_timezones|map(attribute=0)|list %}
<option value="{{ current_tz }}" selected>{{ current_tz }}</option>
{% endif %}
</select>
<div class="form-text">
Current setting: <strong>{{ current_tz }}</strong>
local time is <strong>{{ now_local.strftime('%b %d, %Y %H:%M %Z') }}</strong>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>Save Timezone
</button>
</form>
</div>
</div>
<!-- Branding -->
<div class="card mb-4" style="max-width:680px;">
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
@@ -113,6 +152,98 @@
</div>
</div>
<!-- Email Ingestion -->
<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-envelope-arrow-down me-2"></i>Email-to-Ticket Ingestion
</h5>
</div>
<div class="card-body" style="padding:20px;">
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
When enabled, TechDesk polls a dedicated IMAP mailbox and automatically converts
inbound emails into tickets. Use a dedicated support inbox with an app-specific password.
</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="email_ingestion">
<!-- Enable toggle -->
<div class="d-flex align-items-center gap-3 mb-4 p-3"
style="border:1px solid var(--border);border-radius:10px;background:var(--surface);">
<div style="flex:1;">
<div style="font-weight:600;font-size:14px;">
<i class="bi bi-power me-2"></i>Enable Email Ingestion
</div>
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
Start polling the mailbox below for new tickets.
</div>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch"
id="email-ingestion-toggle" name="email_ingestion_enabled" value="1"
{% if email_settings.enabled %}checked{% endif %}
style="width:40px;height:22px;cursor:pointer;"/>
</div>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">IMAP Host</label>
<input type="text" class="form-control" name="email_ingestion_host"
value="{{ email_settings.host }}" placeholder="e.g. imap.gmail.com"/>
</div>
<div class="col-md-4">
<label class="form-label">Port</label>
<input type="number" class="form-control" name="email_ingestion_port"
value="{{ email_settings.port }}" placeholder="993"/>
</div>
<div class="col-md-6">
<label class="form-label">Mailbox Username / Email</label>
<input type="text" class="form-control" name="email_ingestion_user"
value="{{ email_settings.user }}" placeholder="support@yourcompany.com"/>
</div>
<div class="col-md-6">
<label class="form-label">Password
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— use an app-specific password
</span>
</label>
<input type="password" class="form-control" name="email_ingestion_password"
value="{{ email_settings.password }}" placeholder="Leave blank to keep current"/>
</div>
<div class="col-md-4">
<label class="form-label">Watch Folder</label>
<input type="text" class="form-control" name="email_ingestion_folder"
value="{{ email_settings.folder }}" placeholder="INBOX"/>
</div>
<div class="col-md-4">
<label class="form-label">Processed Folder</label>
<input type="text" class="form-control" name="email_ingestion_move_to"
value="{{ email_settings.move_to }}" placeholder="Processed"/>
</div>
<div class="col-md-4">
<label class="form-label">Poll Interval (minutes)</label>
<input type="number" class="form-control" name="email_ingestion_interval"
value="{{ email_settings.interval }}" min="1" max="60" placeholder="5"/>
</div>
</div>
<div class="alert alert-info mt-3 mb-0" style="font-size:12px;">
<i class="bi bi-info-circle me-2"></i>
<strong>Note:</strong> For Gmail, enable IMAP in Settings → Forwarding and POP/IMAP,
and use an App Password (Google Account → Security → 2-Step Verification → App passwords).
For other providers, ensure IMAP is enabled and check their specific settings.
</div>
<button type="submit" class="btn btn-primary mt-3">
<i class="bi bi-check2 me-2"></i>Save Email Settings
</button>
</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);">
+106 -1
View File
@@ -65,6 +65,10 @@
<table class="table mb-0">
<thead>
<tr>
<th style="width:36px;">
<input type="checkbox" id="select-all" title="Select all"
style="accent-color:var(--accent);width:15px;height:15px;cursor:pointer;"/>
</th>
<th>Ticket #</th>
<th>Title</th>
<th>Category</th>
@@ -79,6 +83,10 @@
<tbody>
{% for t in tickets.items %}
<tr>
<td>
<input type="checkbox" class="ticket-cb" value="{{ t.id }}"
style="accent-color:var(--accent);width:15px;height:15px;cursor:pointer;"/>
</td>
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</a></td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.title }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
@@ -86,7 +94,7 @@
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
<td style="font-size:11px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
<td style="font-size:11px;color:var(--muted);">{{ t.created_at | localtime("%b %d") }}</td>
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a></td>
</tr>
{% endfor %}
@@ -121,4 +129,101 @@
{% endif %}
</div>
</div>
<!-- Bulk action bar: fixed at bottom, hidden until checkboxes are ticked -->
<!-- NOTE: display is set entirely via JS — no inline display property here -->
<div id="bulk-action-bar"
style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
background:#1e293b;color:#fff;border-radius:12px;padding:12px 20px;
box-shadow:0 8px 32px rgba(0,0,0,.3);z-index:1000;
align-items:center;gap:12px;min-width:420px;">
<span id="bulk-count" style="font-size:13px;font-weight:600;white-space:nowrap;"></span>
<div style="flex:1;"></div>
<form method="POST" action="{{ url_for('admin.bulk_ticket_action') }}" id="bulk-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="status" value="{{ status }}"/>
<input type="hidden" name="priority" value="{{ priority }}"/>
<input type="hidden" name="assigned" value="{{ assigned }}"/>
<input type="hidden" name="q" value="{{ search }}"/>
<div id="bulk-hidden-ids"></div>
<div class="d-flex gap-2">
<button type="submit" name="action" value="resolve"
class="btn btn-sm" style="background:#059669;color:#fff;border:none;">
<i class="bi bi-check2-circle me-1"></i>Resolve
</button>
<button type="submit" name="action" value="close"
class="btn btn-sm" style="background:#6b7280;color:#fff;border:none;">
<i class="bi bi-archive me-1"></i>Close
</button>
<button type="submit" name="action" value="assign_me"
class="btn btn-sm" style="background:#2563eb;color:#fff;border:none;">
<i class="bi bi-person-check me-1"></i>Assign to Me
</button>
<button type="submit" name="action" value="unassign"
class="btn btn-sm" style="color:#fff;border:1px solid #6b7280;background:transparent;">
<i class="bi bi-person-dash me-1"></i>Unassign
</button>
</div>
</form>
<button type="button" onclick="clearSelection()"
style="background:none;border:none;color:#9ca3af;font-size:20px;cursor:pointer;
line-height:1;padding:0 0 0 4px;flex-shrink:0;" title="Clear selection">×</button>
</div>
<!-- Script is AFTER all DOM elements so querySelectorAll finds the checkboxes -->
<script>
(function () {
const bar = document.getElementById('bulk-action-bar');
const countEl = document.getElementById('bulk-count');
const hiddenIds = document.getElementById('bulk-hidden-ids');
const selectAll = document.getElementById('select-all');
// Start hidden — JS controls visibility entirely
bar.style.display = 'none';
function getChecked() {
return Array.from(document.querySelectorAll('.ticket-cb:checked'));
}
function updateBar() {
const checked = getChecked();
if (checked.length === 0) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
countEl.textContent = `${checked.length} ticket${checked.length !== 1 ? 's' : ''} selected`;
hiddenIds.innerHTML = checked.map(cb =>
`<input type="hidden" name="ticket_ids" value="${cb.value}"/>`
).join('');
}
window.clearSelection = function () {
document.querySelectorAll('.ticket-cb').forEach(cb => cb.checked = false);
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
updateBar();
};
if (selectAll) {
selectAll.addEventListener('change', () => {
document.querySelectorAll('.ticket-cb').forEach(cb => {
cb.checked = selectAll.checked;
});
updateBar();
});
}
document.querySelectorAll('.ticket-cb').forEach(cb => {
cb.addEventListener('change', () => {
const all = document.querySelectorAll('.ticket-cb');
const done = document.querySelectorAll('.ticket-cb:checked');
if (selectAll) {
selectAll.indeterminate = done.length > 0 && done.length < all.length;
selectAll.checked = done.length === all.length && all.length > 0;
}
updateBar();
});
});
})();
</script>
{% endblock %}
@@ -69,7 +69,7 @@
<td>{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %}</td>
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.created_at | localtime("%b %d") }}</td>
</tr>
{% endfor %}
</tbody>
+7 -7
View File
@@ -25,7 +25,7 @@
</span>
{% endif %}
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span>
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %H:%M') }}</span>
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at | localtime("%b %d, %Y %H:%M") }}</span>
{% if ticket.location %}<span><i class="bi bi-geo-alt me-1"></i>{{ ticket.location }}</span>{% endif %}
{% if ticket.asset_tag %}<span><i class="bi bi-cpu me-1"></i>{{ ticket.asset_tag }}</span>{% endif %}
{% if ticket.ai_generated %}<span style="color:var(--accent3);"><i class="bi bi-robot me-1"></i>AI-generated</span>{% endif %}
@@ -113,7 +113,7 @@
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<span class="comment-time">{{ comment.created_at.strftime('%b %d, %Y %H:%M') }}</span>
<span class="comment-time">{{ comment.created_at | localtime("%b %d, %Y %H:%M") }}</span>
{% if current_user.is_it_staff or comment.author_id == current_user.id %}
<form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
onsubmit="return confirm('Delete this comment?');">
@@ -744,10 +744,10 @@ function buildCommentEl(c) {
{{ info_row('bi-person-badge', 'Filed by (IT)', '<span style="color:var(--accent);font-weight:600;">' ~ ticket.filed_by_staff.full_name ~ '</span>') }}
{% endif %}
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
{{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }}
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }}
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date.strftime('%b %d, %Y')) }}{% endif %}
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at.strftime('%b %d, %Y')) }}{% endif %}
{{ info_row('bi-calendar3', 'Created', ticket.created_at | localtime("%b %d, %Y")) }}
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at | localtime("%b %d, %Y")) }}
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date | localtime("%b %d, %Y")) }}{% endif %}
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at | localtime("%b %d, %Y")) }}{% endif %}
</div>
</div>
@@ -778,7 +778,7 @@ function buildCommentEl(c) {
{% endif %}
changed from <em>{{ old }}</em> to <em>{{ new }}</em>
<br>
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at | localtime("%b %d %H:%M") }}</span>
</div>
{% endfor %}
</div>
+56 -9
View File
@@ -83,18 +83,65 @@
</div>
</div>
<div class="card mt-3">
<div class="card-body d-flex align-items-center justify-content-between">
<span style="font-size:13px;color:var(--muted);">Did this article solve your issue?</span>
<div class="d-flex gap-2">
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-ticket me-1"></i>Still need help
</a>
<div class="card-body">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<!-- Feedback widget -->
<div style="font-size:13px;">
<span style="color:var(--muted);margin-right:10px;">Was this article helpful?</span>
<button type="button" id="fb-yes"
onclick="submitFeedback(1)"
class="btn btn-sm {% if user_feedback and user_feedback.is_helpful %}btn-success{% else %}btn-outline-secondary{% endif %}"
style="margin-right:6px;">
<i class="bi bi-hand-thumbs-up me-1"></i>
Yes <span id="fb-yes-count">({{ helpful_count }})</span>
</button>
<button type="button" id="fb-no"
onclick="submitFeedback(0)"
class="btn btn-sm {% if user_feedback and not user_feedback.is_helpful %}btn-danger{% else %}btn-outline-secondary{% endif %}">
<i class="bi bi-hand-thumbs-down me-1"></i>
No <span id="fb-no-count">({{ not_helpful_count }})</span>
</button>
<span id="fb-msg" style="font-size:12px;color:var(--muted);margin-left:10px;"></span>
</div>
<!-- Navigation -->
<div class="d-flex gap-2">
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-ticket me-1"></i>Still need help
</a>
</div>
</div>
</div>
</div>
<script>
const FEEDBACK_URL = '{{ url_for("tickets.kb_feedback", article_id=article.id) }}';
const CSRF_TOKEN = document.querySelector('meta[name=csrf-token]').content;
async function submitFeedback(helpful) {
const fd = new FormData();
fd.append('helpful', helpful);
fd.append('csrf_token', CSRF_TOKEN);
try {
const r = await fetch(FEEDBACK_URL, { method: 'POST', credentials: 'same-origin', body: fd });
const data = await r.json();
if (!data.ok) return;
document.getElementById('fb-yes-count').textContent = `(${data.helpful_count})`;
document.getElementById('fb-no-count').textContent = `(${data.not_helpful_count})`;
const btnYes = document.getElementById('fb-yes');
const btnNo = document.getElementById('fb-no');
const msg = document.getElementById('fb-msg');
btnYes.className = 'btn btn-sm ' + (data.user_vote === 'helpful' ? 'btn-success' : 'btn-outline-secondary');
btnNo.className = 'btn btn-sm ' + (data.user_vote === 'not_helpful' ? 'btn-danger' : 'btn-outline-secondary');
msg.textContent = data.user_vote ? 'Thanks for your feedback!' : 'Vote removed.';
setTimeout(() => msg.textContent = '', 3000);
} catch {
// silent fail
}
}
</script>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -93,7 +93,7 @@
{% if current_user.is_it_staff %}
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
{% endif %}
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d, %Y') }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.created_at | localtime("%b %d, %Y") }}</td>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-eye"></i>
+1 -1
View File
@@ -39,7 +39,7 @@
<div style="font-size:12px;color:var(--muted);margin-top:3px;">{{ n.message[:120] }}</div>
{% endif %}
<div style="font-size:11px;color:var(--muted);margin-top:5px;font-family:'Space Mono',monospace;">
{{ n.created_at.strftime('%b %d, %Y at %H:%M') }}
{{ n.created_at | localtime("%b %d, %Y at %H:%M") }}
</div>
</div>
<div style="flex-shrink:0;align-self:center;">