04/07 updated timezone, report via email, etc
This commit is contained in:
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
@@ -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">
|
||||
|
||||
@@ -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 & 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);">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user