04/07 updated timezone, report via email, etc
This commit is contained in:
+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':
|
||||
|
||||
Reference in New Issue
Block a user