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_PORT=465
MAIL_USE_TLS=False MAIL_USE_TLS=False
MAIL_USE_SSL=True MAIL_USE_SSL=True
MAIL_USERNAME=it.helpdesk@ltservicesinc.com MAIL_USERNAME=donotreply@ltservicesinc.com
MAIL_PASSWORD=IT*H3lpD35k! MAIL_PASSWORD=M-6ZW+omp7n]
MAIL_DEFAULT_SENDER=IT Helpdesk <it.helpdesk@ltservicesinc.com> MAIL_DEFAULT_SENDER=IT Helpdesk <donotreply@ltservicesinc.com>
# IT Department Email (receives all new ticket notifications) # IT Department Email (receives all new ticket notifications)
IT_DEPT_EMAIL=da.nguyen8744@gmail.com 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. # object is already in session), then fall back to a SELECT by PK.
return db.session.get(User, int(user_id)) 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 ──────────────────────────────────────────────────── # ── Context processors ────────────────────────────────────────────────────
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
@@ -136,7 +163,17 @@ def create_app(config_name=None):
'logo_initials' : SystemSetting.get('logo_initials', 'TD'), 'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'), '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) ───────────────────────────────────────── # ── DB initialisation (first run) ─────────────────────────────────────────
with app.app_context(): with app.app_context():
@@ -144,6 +181,9 @@ def create_app(config_name=None):
_seed_admin(app) _seed_admin(app)
_seed_settings() _seed_settings()
# ── Email ingestion background scheduler ────────────────────────────────────
_start_email_ingestion_scheduler(app)
# ── SLA background scheduler ────────────────────────────────────────────── # ── SLA background scheduler ──────────────────────────────────────────────
# APScheduler runs inside the gunicorn worker process (single-worker # APScheduler runs inside the gunicorn worker process (single-worker
# eventlet setup), so no cross-process coordination is needed. # 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}') 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(): 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
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'),
('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_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_high_hours', '8', 'Hours before a HIGH ticket is considered overdue'),
('sla_medium_hours', '48', 'Hours before a MEDIUM 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'), ('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): def __repr__(self):
return f'<CannedResponse {self.title}>' 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 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)
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 from app.services.validation_service import validate_password, validate_file
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') 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')) 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(): def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
@@ -957,6 +1060,58 @@ def settings():
flash(f'User registration has been {state_label}.', 'success') flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings')) 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 ─────────────────────────────────────────────────── # ── Branding update ───────────────────────────────────────────────────
if form_type == 'branding': if form_type == 'branding':
fields = { fields = {
@@ -1022,6 +1177,16 @@ 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)
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 = { branding_settings = {
'app_name' : SystemSetting.get('app_name', 'TechDesk'), 'app_name' : SystemSetting.get('app_name', 'TechDesk'),
'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'), 'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'),
@@ -1030,6 +1195,31 @@ def settings():
'logo_initials' : SystemSetting.get('logo_initials', 'TD'), 'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'), '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', return render_template('admin/settings.html',
registration_enabled=registration_enabled, 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 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)
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,
@@ -674,7 +675,19 @@ def kb_article(article_id):
db.session.commit() db.session.commit()
# Re-fetch so the template receives the post-increment value. # Re-fetch so the template receives the post-increment value.
db.session.refresh(article) 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 ──────────────────────────────────────────────────── # ─── 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 ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime': 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 %} {% if articles %}
<table class="table mb-0"> <table class="table mb-0">
<thead> <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> </thead>
<tbody> <tbody>
{% for art in articles %} {% for art in articles %}
@@ -81,6 +81,13 @@
{% endif %} {% endif %}
</td> </td>
<td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</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 style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td>
<td> <td>
<div class="d-flex gap-1 align-items-center"> <div class="d-flex gap-1 align-items-center">
+131
View File
@@ -18,6 +18,45 @@
{% endfor %} {% endfor %}
{% endwith %} {% 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 --> <!-- Branding -->
<div class="card mb-4" style="max-width:680px;"> <div class="card mb-4" 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);">
@@ -113,6 +152,98 @@
</div> </div>
</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 --> <!-- 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);">
+106 -1
View File
@@ -65,6 +65,10 @@
<table class="table mb-0"> <table class="table mb-0">
<thead> <thead>
<tr> <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>Ticket #</th>
<th>Title</th> <th>Title</th>
<th>Category</th> <th>Category</th>
@@ -79,6 +83,10 @@
<tbody> <tbody>
{% for t in tickets.items %} {% for t in tickets.items %}
<tr> <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><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: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> <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><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;">{{ 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: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> <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> </tr>
{% endfor %} {% endfor %}
@@ -121,4 +129,101 @@
{% endif %} {% endif %}
</div> </div>
</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 %} {% endblock %}
@@ -69,7 +69,7 @@
<td>{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %}</td> <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.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+7 -7
View File
@@ -25,7 +25,7 @@
</span> </span>
{% endif %} {% endif %}
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span> <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.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.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 %} {% 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 %} {% endif %}
</div> </div>
<div class="d-flex align-items-center gap-2"> <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 %} {% 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) }}" <form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
onsubmit="return confirm('Delete this comment?');"> 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>') }} {{ info_row('bi-person-badge', 'Filed by (IT)', '<span style="color:var(--accent);font-weight:600;">' ~ ticket.filed_by_staff.full_name ~ '</span>') }}
{% endif %} {% endif %}
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }} {{ 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-calendar3', 'Created', ticket.created_at | localtime("%b %d, %Y")) }}
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%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.strftime('%b %d, %Y')) }}{% endif %} {% 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.strftime('%b %d, %Y')) }}{% endif %} {% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at | localtime("%b %d, %Y")) }}{% endif %}
</div> </div>
</div> </div>
@@ -778,7 +778,7 @@ function buildCommentEl(c) {
{% endif %} {% endif %}
changed from <em>{{ old }}</em> to <em>{{ new }}</em> changed from <em>{{ old }}</em> to <em>{{ new }}</em>
<br> <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> </div>
{% endfor %} {% endfor %}
</div> </div>
+49 -2
View File
@@ -83,8 +83,27 @@
</div> </div>
</div> </div>
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body d-flex align-items-center justify-content-between"> <div class="card-body">
<span style="font-size:13px;color:var(--muted);">Did this article solve your issue?</span> <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"> <div class="d-flex gap-2">
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back <i class="bi bi-arrow-left me-1"></i>Back
@@ -96,5 +115,33 @@
</div> </div>
</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> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -93,7 +93,7 @@
{% if current_user.is_it_staff %} {% if current_user.is_it_staff %}
<td style="font-size:13px;">{{ t.creator.full_name }}</td> <td style="font-size:13px;">{{ t.creator.full_name }}</td>
{% endif %} {% 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> <td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"> <a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-eye"></i> <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> <div style="font-size:12px;color:var(--muted);margin-top:3px;">{{ n.message[:120] }}</div>
{% endif %} {% endif %}
<div style="font-size:11px;color:var(--muted);margin-top:5px;font-family:'Space Mono',monospace;"> <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> </div>
<div style="flex-shrink:0;align-self:center;"> <div style="flex-shrink:0;align-self:center;">