05/22 Enhance codes and fix bugs 3
This commit is contained in:
+22
-3
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user