05/22 Enhance codes and fix bugs 3

This commit is contained in:
2026-05-22 15:19:34 -04:00
parent 12b5fd9ce0
commit 5c9a124a71
11 changed files with 665 additions and 36 deletions
+22 -3
View File
@@ -425,10 +425,14 @@ def all_tickets():
.distinct()
)
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
tickets = q.order_by(Ticket.created_at.desc()).paginate(page=page, per_page=25)
it_staff = User.query.filter(
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
User.is_active == True
).order_by(User.full_name).all()
return render_template('admin/tickets.html', tickets=tickets,
status=status, priority=priority, assigned=assigned,
search=search)
search=search, it_staff=it_staff)
@admin_bp.route('/tickets/<int:ticket_id>/delete', methods=['POST'])
@@ -993,11 +997,16 @@ def bulk_ticket_action():
flash('No tickets selected.', 'warning')
return redirect(url_for('admin.all_tickets'))
valid_actions = ('resolve', 'close', 'assign_me', 'unassign', 'delete')
valid_actions = ('resolve', 'close', 'assign_me', 'unassign', 'assign_to', 'delete')
if action not in valid_actions:
flash('Invalid action.', 'danger')
return redirect(url_for('admin.all_tickets'))
assign_to_id = request.form.get('assign_to_id', type=int)
if action == 'assign_to' and not assign_to_id:
flash('Please select a staff member to assign to.', 'warning')
return redirect(url_for('admin.all_tickets'))
# Bulk delete is admin-only
if action == 'delete' and not current_user.is_admin:
flash('Only administrators may delete tickets.', 'danger')
@@ -1071,6 +1080,15 @@ def bulk_ticket_action():
current_user.full_name, current_user.id)
changed = True
elif action == 'assign_to' and ticket.assigned_to_id != assign_to_id:
target = db.session.get(User, assign_to_id)
if target:
ticket.assigned_to_id = assign_to_id
log_ticket_history(ticket, 'assigned_to',
old_assigned or 'Unassigned',
target.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',
@@ -1095,6 +1113,7 @@ def bulk_ticket_action():
'resolve': 'resolved',
'close': 'closed',
'assign_me': 'assigned to you',
'assign_to': f'assigned to {db.session.get(User, assign_to_id).full_name if assign_to_id else "staff"}',
'unassign': 'unassigned',
}
flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success')
+40 -4
View File
@@ -4,7 +4,7 @@ import requests
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from app import db, limiter
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory
from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory, KnowledgeBase
from app.services.notification_service import notify_new_ticket
from app.services.log_service import log_action
@@ -35,7 +35,28 @@ _GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions'
_GROQ_MODEL = 'llama-3.3-70b-versatile'
def _call_groq(api_key, history, user_msg):
def _search_kb(query, limit=3):
"""Return up to `limit` published KB articles relevant to the user query.
Uses a simple keyword presence check against the title and tags columns.
This avoids full-text indexes and works across all MySQL configs.
"""
import re
words = [w for w in re.split(r'\W+', query.lower()) if len(w) > 3]
if not words:
return []
articles = KnowledgeBase.query.filter_by(is_published=True).all()
scored = []
for art in articles:
haystack = (art.title + ' ' + (art.tags or '')).lower()
score = sum(1 for w in words if w in haystack)
if score:
scored.append((score, art))
scored.sort(key=lambda x: -x[0])
return [art for _, art in scored[:limit]]
def _call_groq(api_key, history, user_msg, kb_context=''):
"""
Call the Groq API (OpenAI-compatible) and return the assistant's reply text.
@@ -78,8 +99,12 @@ def _call_groq(api_key, history, user_msg):
for msg in clean_history
]
system_content = _SYSTEM_PROMPT
if kb_context:
system_content += '\n\n' + kb_context
messages = (
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
[{'role': 'system', 'content': system_content}]
+ clean_history
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
)
@@ -119,8 +144,19 @@ def chat():
'ticket': None,
})
# Inject relevant KB articles as context so the chatbot can reference
# self-help content before suggesting a ticket is needed.
kb_articles = _search_kb(user_msg)
kb_context = ''
if kb_articles:
lines = ['RELEVANT KNOWLEDGE BASE ARTICLES (reference these if applicable):']
base_url = current_app.config.get('APP_BASE_URL', '')
for art in kb_articles:
lines.append(f'- {art.title}: {base_url}/kb/{art.id}')
kb_context = '\n'.join(lines)
try:
reply_text = _call_groq(api_key, history, user_msg)
reply_text = _call_groq(api_key, history, user_msg, kb_context=kb_context)
except Exception as exc:
logger.error(f'[CHATBOT API ERROR] {exc}')
return jsonify({
+77 -2
View File
@@ -10,11 +10,12 @@ from app import db
from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
KBFeedback, TicketTemplate, TicketSatisfaction)
KBFeedback, TicketTemplate, TicketSatisfaction,
TicketWatcher, TimeEntry)
from app.services.notification_service import (
notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment,
send_satisfaction_survey,
send_satisfaction_survey, notify_watchers,
)
from app.services.log_service import log_action, log_ticket_history
from app.services.sla_service import clear_sla_notification
@@ -446,12 +447,21 @@ def ticket_detail(ticket_id):
CannedResponse.category, CannedResponse.title
).all()
is_watching = TicketWatcher.query.filter_by(
ticket_id=ticket_id, user_id=current_user.id
).first() is not None
total_minutes = sum(e.minutes for e in ticket.time_entries.all())
return render_template('tickets/detail.html',
ticket=ticket, comments=comments,
it_staff=it_staff, history=history,
statuses=_statuses(), priorities=_priorities(),
linked_tickets=linked_tickets,
canned_responses=canned_responses,
now=datetime.utcnow(),
is_watching=is_watching,
total_minutes=total_minutes,
time_entries=ticket.time_entries.order_by(TimeEntry.logged_at.desc()).limit(10).all(),
)
@@ -538,6 +548,14 @@ def update_ticket(ticket_id):
if new_status == TicketStatus.RESOLVED:
send_satisfaction_survey(ticket)
if changes:
notify_watchers(
ticket,
event_title = f'Ticket {ticket.ticket_number} updated',
event_message = '; '.join(changes),
exclude_user_id = current_user.id,
)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
from flask import jsonify
return jsonify({'ok': True, 'changes': changes,
@@ -976,6 +994,63 @@ def ticket_survey(token):
quick_rating=quick_rating)
# ─── Ticket Watchers ─────────────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>/watch', methods=['POST'])
@login_required
def watch_ticket(ticket_id):
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
existing = TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).first()
if not existing:
db.session.add(TicketWatcher(ticket_id=ticket_id, user_id=current_user.id))
db.session.commit()
from flask import jsonify
return jsonify({'watching': True})
@tickets_bp.route('/tickets/<int:ticket_id>/unwatch', methods=['POST'])
@login_required
def unwatch_ticket(ticket_id):
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).delete()
db.session.commit()
from flask import jsonify
return jsonify({'watching': False})
# ─── Time Tracking ────────────────────────────────────────────────────────────
@tickets_bp.route('/tickets/<int:ticket_id>/log-time', methods=['POST'])
@login_required
def log_time(ticket_id):
if not current_user.is_it_staff:
abort(403)
ticket = db.session.get(Ticket, ticket_id) or abort(404)
try:
minutes = int(request.form.get('minutes', 0))
except (ValueError, TypeError):
minutes = 0
if minutes <= 0:
flash('Please enter a valid number of minutes.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
note = request.form.get('note', '').strip()[:200]
entry = TimeEntry(ticket_id=ticket_id, user_id=current_user.id, minutes=minutes, note=note)
db.session.add(entry)
log_action(current_user.id, 'time_log', 'ticket', ticket_id,
f'minutes={minutes} note={note!r}')
db.session.commit()
from flask import jsonify
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
total = sum(e.minutes for e in ticket.time_entries.all())
return jsonify({'ok': True, 'minutes': minutes, 'total_minutes': total})
flash(f'{minutes} minutes logged.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime':