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
+192 -2
View File
@@ -12,7 +12,7 @@ import bleach
from app import db, limiter
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus, CannedResponse)
from app.services.log_service import log_action
from app.services.log_service import log_action, log_ticket_history
from app.services.validation_service import validate_password, validate_file
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -930,6 +930,109 @@ def canned_response_delete(item_id):
return redirect(url_for('admin.canned_responses'))
# ─── Bulk Ticket Actions ─────────────────────────────────────────────────────
@admin_bp.route('/tickets/bulk-action', methods=['POST'])
@login_required
@it_required
def bulk_ticket_action():
"""Apply a single action to multiple selected tickets at once.
Accepted actions:
- resolve → set status to Resolved, stamp resolved_at
- close → set status to Closed, stamp closed_at
- assign_me → assign all selected tickets to current user
- unassign → clear assigned_to_id
"""
from app.services.notification_service import notify_status_change
from app.services.sla_service import clear_sla_notification
action = request.form.get('action', '')
ticket_ids = request.form.getlist('ticket_ids', type=int)
if not ticket_ids:
flash('No tickets selected.', 'warning')
return redirect(url_for('admin.all_tickets'))
valid_actions = ('resolve', 'close', 'assign_me', 'unassign')
if action not in valid_actions:
flash('Invalid action.', 'danger')
return redirect(url_for('admin.all_tickets'))
tickets = Ticket.query.filter(Ticket.id.in_(ticket_ids)).all()
now = datetime.utcnow()
count = 0
notif_tickets = [] # collect for post-commit notifications
for ticket in tickets:
old_status = ticket.status
old_assigned = ticket.assigned_to_id
changed = False
if action == 'resolve' and ticket.status not in (
TicketStatus.RESOLVED, TicketStatus.CLOSED):
ticket.status = TicketStatus.RESOLVED
ticket.resolved_at = now
clear_sla_notification(ticket.id)
log_ticket_history(ticket, 'status', old_status,
TicketStatus.RESOLVED, current_user.id)
notif_tickets.append((ticket, old_status))
changed = True
elif action == 'close' and ticket.status != TicketStatus.CLOSED:
ticket.status = TicketStatus.CLOSED
ticket.closed_at = now
clear_sla_notification(ticket.id)
log_ticket_history(ticket, 'status', old_status,
TicketStatus.CLOSED, current_user.id)
notif_tickets.append((ticket, old_status))
changed = True
elif action == 'assign_me' and ticket.assigned_to_id != current_user.id:
ticket.assigned_to_id = current_user.id
log_ticket_history(ticket, 'assigned_to',
old_assigned or 'Unassigned',
current_user.full_name, current_user.id)
changed = True
elif action == 'unassign' and ticket.assigned_to_id is not None:
ticket.assigned_to_id = None
log_ticket_history(ticket, 'assigned_to',
old_assigned or 'Unassigned',
'Unassigned', current_user.id)
changed = True
if changed:
log_action(current_user.id, f'ticket_bulk_{action}', 'ticket',
ticket.id, f'action={action}')
count += 1
db.session.commit()
logger.info(f'[BULK ACTION] action={action} affected={count} '
f'ticket_ids={ticket_ids} by user_id={current_user.id}')
# Send status-change notifications after commit
for ticket, old_status in notif_tickets:
notify_status_change(ticket, old_status, current_user)
action_labels = {
'resolve': 'resolved',
'close': 'closed',
'assign_me': 'assigned to you',
'unassign': 'unassigned',
}
flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success')
# Preserve current filter params on redirect
return redirect(url_for('admin.all_tickets',
status = request.form.get('status', ''),
priority = request.form.get('priority', ''),
assigned = request.form.get('assigned', ''),
q = request.form.get('q', ''),
page = request.form.get('page', 1),
))
def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
@@ -957,6 +1060,58 @@ def settings():
flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings'))
# ── Timezone setting ──────────────────────────────────────────────────
if form_type == 'timezone':
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
new_tz = request.form.get('app_timezone', 'America/New_York').strip()
try:
ZoneInfo(new_tz) # validate before saving
except (ZoneInfoNotFoundError, KeyError):
flash(f'Unknown timezone "{new_tz}". Please select a valid timezone.', 'danger')
return redirect(url_for('admin.settings'))
old_tz = SystemSetting.get('app_timezone', 'America/New_York')
SystemSetting.set('app_timezone', new_tz, 'Display timezone for all dates and times in the UI')
db.session.commit()
log_action(current_user.id, 'setting_update', 'system_setting', None,
f'app_timezone changed from {old_tz!r} to {new_tz!r}')
logger.info(f'[ADMIN SETTINGS] app_timezone={new_tz} by admin_id={current_user.id}')
flash(f'Timezone updated to {new_tz}.', 'success')
return redirect(url_for('admin.settings'))
# ── Email ingestion settings ──────────────────────────────────────────
if form_type == 'email_ingestion':
fields = {
'email_ingestion_enabled' : request.form.get('email_ingestion_enabled', '0'),
'email_ingestion_host' : request.form.get('email_ingestion_host', '').strip(),
'email_ingestion_port' : request.form.get('email_ingestion_port', '993').strip(),
'email_ingestion_user' : request.form.get('email_ingestion_user', '').strip(),
'email_ingestion_folder' : request.form.get('email_ingestion_folder', 'INBOX').strip(),
'email_ingestion_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(),
'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').strip(),
}
# Password: only update if a new value was provided
new_pw = request.form.get('email_ingestion_password', '').strip()
if new_pw:
fields['email_ingestion_password'] = new_pw
changes = []
for key, value in fields.items():
old_val = SystemSetting.get(key, '')
if str(value) != str(old_val):
SystemSetting.set(key, value)
safe_key = key.replace('email_ingestion_', '')
# Never log the password value
changes.append(f'{safe_key}={"[updated]" if "password" in key else repr(value)}')
db.session.commit()
if changes:
log_action(current_user.id, 'email_ingestion_settings_update',
'system_setting', None, ', '.join(changes))
logger.info(f'[ADMIN EMAIL INGEST] Settings updated: {", ".join(changes)} '
f'by admin_id={current_user.id}')
flash('Email ingestion settings saved.', 'success')
return redirect(url_for('admin.settings'))
# ── Branding update ───────────────────────────────────────────────────
if form_type == 'branding':
fields = {
@@ -1022,6 +1177,16 @@ def settings():
return redirect(url_for('admin.settings'))
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
email_settings = {
'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False),
'host' : SystemSetting.get('email_ingestion_host', ''),
'port' : SystemSetting.get('email_ingestion_port', '993'),
'user' : SystemSetting.get('email_ingestion_user', ''),
'password': SystemSetting.get('email_ingestion_password', ''),
'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'),
'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'),
'interval': SystemSetting.get('email_ingestion_interval', '5'),
}
branding_settings = {
'app_name' : SystemSetting.get('app_name', 'TechDesk'),
'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'),
@@ -1030,6 +1195,31 @@ def settings():
'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'),
}
current_tz = SystemSetting.get('app_timezone', 'America/New_York')
# Common timezone list for the selector
common_timezones = [
('America/New_York', 'Eastern Time (ET) — New York'),
('America/Chicago', 'Central Time (CT) — Chicago'),
('America/Denver', 'Mountain Time (MT) — Denver'),
('America/Phoenix', 'Mountain Time, no DST — Phoenix'),
('America/Los_Angeles', 'Pacific Time (PT) — Los Angeles'),
('America/Anchorage', 'Alaska Time — Anchorage'),
('Pacific/Honolulu', 'Hawaii Time — Honolulu'),
('America/Puerto_Rico', 'Atlantic Time — Puerto Rico'),
('UTC', 'UTC — Coordinated Universal Time'),
('Europe/London', 'GMT/BST — London'),
('Europe/Paris', 'CET/CEST — Paris, Berlin'),
('Europe/Helsinki', 'EET/EEST — Helsinki, Athens'),
('Asia/Dubai', 'GST — Dubai'),
('Asia/Kolkata', 'IST — India'),
('Asia/Singapore', 'SGT — Singapore'),
('Asia/Tokyo', 'JST — Tokyo'),
('Australia/Sydney', 'AEST/AEDT — Sydney'),
('Pacific/Auckland', 'NZST/NZDT — Auckland'),
]
return render_template('admin/settings.html',
registration_enabled=registration_enabled,
branding_settings=branding_settings)
branding_settings=branding_settings,
email_settings=email_settings,
current_tz=current_tz,
common_timezones=common_timezones)
+79 -2
View File
@@ -9,7 +9,8 @@ from werkzeug.utils import secure_filename
from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse)
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
KBFeedback)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
@@ -674,7 +675,19 @@ def kb_article(article_id):
db.session.commit()
# Re-fetch so the template receives the post-increment value.
db.session.refresh(article)
return render_template('tickets/kb_article.html', article=article)
# Load the current user's vote (if any) so the feedback widget shows state
user_feedback = KBFeedback.query.filter_by(
article_id=article.id, user_id=current_user.id
).first()
# Aggregate counts for the widget
helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count()
not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count()
return render_template('tickets/kb_article.html',
article=article,
user_feedback=user_feedback,
helpful_count=helpful_count,
not_helpful_count=not_helpful_count,
)
# ─── Ticket Link / Unlink ────────────────────────────────────────────────────
@@ -840,6 +853,70 @@ def get_canned_responses():
]})
# ─── KB Article Feedback ─────────────────────────────────────────────────────
@tickets_bp.route('/kb/<int:article_id>/feedback', methods=['POST'])
@login_required
def kb_feedback(article_id):
"""Record or update a thumbs-up / thumbs-down vote for a KB article.
One vote per user per article — subsequent submissions update the existing
row. Submitting the same vote a second time toggles it off (removes it),
giving users an undo path.
"""
article = db.session.get(KnowledgeBase, article_id) or abort(404)
value = request.form.get('helpful') # '1' = helpful, '0' = not helpful
if value not in ('0', '1'):
abort(400)
is_helpful = value == '1'
existing = KBFeedback.query.filter_by(
article_id=article.id, user_id=current_user.id
).first()
if existing:
if existing.is_helpful == is_helpful:
# Same vote again → toggle off (remove)
db.session.delete(existing)
log_action(current_user.id, 'kb_feedback_remove', 'knowledge_base',
article.id, f'was_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK REMOVE] article_id={article.id} '
f'user_id={current_user.id} was_helpful={is_helpful}')
else:
# Changed vote → update
existing.is_helpful = is_helpful
log_action(current_user.id, 'kb_feedback_update', 'knowledge_base',
article.id, f'is_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK UPDATE] article_id={article.id} '
f'user_id={current_user.id} is_helpful={is_helpful}')
else:
fb = KBFeedback(
article_id = article.id,
user_id = current_user.id,
is_helpful = is_helpful,
)
db.session.add(fb)
log_action(current_user.id, 'kb_feedback_create', 'knowledge_base',
article.id, f'is_helpful={is_helpful}')
logger.info(f'[KB FEEDBACK CREATE] article_id={article.id} '
f'user_id={current_user.id} is_helpful={is_helpful}')
db.session.commit()
# Return JSON so the widget can update without a full reload
helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count()
not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count()
from flask import jsonify
return jsonify({
'ok' : True,
'helpful_count' : helpful_count,
'not_helpful_count': not_helpful_count,
'user_vote' : None if (existing and existing.is_helpful == is_helpful)
else ('helpful' if is_helpful else 'not_helpful'),
})
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime':