Jul 10 - Update codes to catch up with the single tenant project (Medium)
This commit is contained in:
+235
-39
@@ -6,12 +6,14 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.support import SupportTicket, SupportTicketReply
|
||||
from app.models.support import (SupportTicket, SupportTicketReply,
|
||||
SupportChatSession, SupportChatMessage,
|
||||
SupportKnowledge)
|
||||
from app.models.user import User
|
||||
from app.models.facility import Facility
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.scope import get_customer_scope
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
|
||||
from app.utils.time_utils import now_eastern
|
||||
from app.utils.notifications import notify
|
||||
|
||||
@@ -41,17 +43,33 @@ cannot resolve through guidance, say so clearly and suggest they click \
|
||||
"Submit to Support" to reach the admin team directly.\
|
||||
"""
|
||||
|
||||
_KB_MAX_CHARS = 6000
|
||||
|
||||
# Preset FAQ questions shown as quick-reply chips on first load
|
||||
FAQS = [
|
||||
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
|
||||
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
|
||||
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
|
||||
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
|
||||
{'icon': 'bi-exclamation-circle', 'text': 'How do I track an open issue?'},
|
||||
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||
]
|
||||
|
||||
|
||||
def _system_prompt_with_kb():
|
||||
"""Return the Groq system prompt, appending active knowledge base entries."""
|
||||
try:
|
||||
entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all()
|
||||
except Exception:
|
||||
return _SYSTEM_PROMPT
|
||||
if not entries:
|
||||
return _SYSTEM_PROMPT
|
||||
kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries)
|
||||
if len(kb_text) > _KB_MAX_CHARS:
|
||||
kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)'
|
||||
return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text
|
||||
|
||||
|
||||
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/chat')
|
||||
@@ -65,11 +83,25 @@ def chat():
|
||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||
session_id = request.args.get('session_id', type=int)
|
||||
chat_session = None
|
||||
db_history = []
|
||||
|
||||
if session_id:
|
||||
chat_session = db.session.get(SupportChatSession, session_id)
|
||||
# Security: only show this customer's own sessions
|
||||
if chat_session and chat_session.customer_id != current_user.id:
|
||||
chat_session = None
|
||||
if chat_session:
|
||||
db_history = list(chat_session.messages)
|
||||
|
||||
return render_template('support/chat.html',
|
||||
faqs=FAQS,
|
||||
facilities=facilities,
|
||||
groq_ready=groq_ready)
|
||||
groq_ready=groq_ready,
|
||||
chat_session=chat_session,
|
||||
db_history=db_history)
|
||||
|
||||
|
||||
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
||||
@@ -88,21 +120,47 @@ def chat_message():
|
||||
)})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
history = data.get('messages', []) # list of {role, content} dicts
|
||||
user_message = data.get('message', '').strip()
|
||||
client_sid = data.get('session_id')
|
||||
|
||||
if not user_message:
|
||||
return jsonify({'error': 'Empty message'}), 400
|
||||
|
||||
# ── Resolve or create a chat session ──────────────────────────────────
|
||||
chat_session = None
|
||||
if client_sid:
|
||||
try:
|
||||
chat_session = db.session.get(SupportChatSession, int(client_sid))
|
||||
except (TypeError, ValueError):
|
||||
chat_session = None
|
||||
# Security: disallow cross-customer session access
|
||||
if chat_session and chat_session.customer_id != current_user.id:
|
||||
chat_session = None
|
||||
|
||||
if chat_session is None:
|
||||
chat_session = SupportChatSession(
|
||||
customer_id = current_user.id,
|
||||
title = user_message[:100],
|
||||
created_at = now_eastern(),
|
||||
last_msg_at = now_eastern(),
|
||||
)
|
||||
db.session.add(chat_session)
|
||||
db.session.flush() # get autoincrement ID before referencing in messages
|
||||
|
||||
# ── Load prior turns from DB for Groq context ─────────────────────────
|
||||
prior = (SupportChatMessage.query
|
||||
.filter_by(session_id=chat_session.id)
|
||||
.order_by(SupportChatMessage.created_at.desc())
|
||||
.limit(40).all())
|
||||
prior = list(reversed(prior)) # oldest → newest
|
||||
|
||||
try:
|
||||
from groq import Groq
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||||
# Append prior conversation (cap at last 20 turns to control token usage)
|
||||
for m in history[-20:]:
|
||||
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
||||
messages.append({'role': m['role'], 'content': m['content']})
|
||||
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
|
||||
for m in prior[-20:]:
|
||||
messages.append({'role': m.role, 'content': m.content})
|
||||
messages.append({'role': 'user', 'content': user_message})
|
||||
|
||||
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||
@@ -113,16 +171,55 @@ def chat_message():
|
||||
temperature=0.5,
|
||||
)
|
||||
reply = completion.choices[0].message.content.strip()
|
||||
return jsonify({'reply': reply})
|
||||
|
||||
# Persist both turns and update session timestamp
|
||||
now = now_eastern()
|
||||
db.session.add(SupportChatMessage(
|
||||
session_id=chat_session.id, role='user', content=user_message, created_at=now))
|
||||
db.session.add(SupportChatMessage(
|
||||
session_id=chat_session.id, role='assistant', content=reply, created_at=now))
|
||||
chat_session.last_msg_at = now
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'reply': reply, 'session_id': chat_session.id})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('SUPPORT | Groq error: %s', exc)
|
||||
db.session.rollback()
|
||||
return jsonify({'reply': (
|
||||
"I ran into a problem reaching the AI assistant. "
|
||||
"Please try again, or use **Submit to Support** to contact our team."
|
||||
)})
|
||||
|
||||
|
||||
# ── Customer: my chat conversations ──────────────────────────────────────────
|
||||
|
||||
@bp.route('/my-conversations')
|
||||
@login_required
|
||||
def my_conversations():
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
sessions = (SupportChatSession.query
|
||||
.filter_by(customer_id=current_user.id)
|
||||
.order_by(SupportChatSession.last_msg_at.desc())
|
||||
.all())
|
||||
return render_template('support/my_conversations.html', sessions=sessions)
|
||||
|
||||
|
||||
@bp.route('/my-conversations/<int:session_id>')
|
||||
@login_required
|
||||
def my_conversation_detail(session_id):
|
||||
if current_user.role != 'customer':
|
||||
abort(403)
|
||||
chat_session = db.session.get(SupportChatSession, session_id)
|
||||
if chat_session is None or chat_session.customer_id != current_user.id:
|
||||
abort(404)
|
||||
return render_template('support/conversation_detail.html',
|
||||
chat_session=chat_session,
|
||||
messages=list(chat_session.messages),
|
||||
is_admin=False)
|
||||
|
||||
|
||||
# ── Submit support ticket ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/tickets', methods=['POST'])
|
||||
@@ -235,20 +332,14 @@ def _notify_admins_new_ticket(ticket):
|
||||
|
||||
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||
facility_label = ticket.facility.name if ticket.facility else 'N/A'
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
|
||||
title = f'New support ticket #{ticket.id} from {customer_label}'
|
||||
body = (f'Subject: {ticket.subject}\n'
|
||||
f'Facility: {facility_label}\n\n'
|
||||
f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
notify(recipient=admin, title=title, body=body, link=link, send_email=True)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@@ -338,13 +429,7 @@ def _notify_customer_reply(ticket, reply):
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
link = url_for('support.my_ticket_detail', ticket_id=ticket.id)
|
||||
|
||||
notify(
|
||||
recipient = ticket.customer,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
notify(recipient=ticket.customer, title=title, body=body, link=link, send_email=True)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@@ -361,11 +446,122 @@ def _notify_admins_customer_reply(ticket, reply):
|
||||
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
|
||||
|
||||
for admin in admins:
|
||||
notify(
|
||||
recipient = admin,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
send_email = True,
|
||||
)
|
||||
notify(recipient=admin, title=title, body=body, link=link, send_email=True)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ── Admin: AI conversations list ──────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/conversations')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_conversations():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
sessions = (SupportChatSession.query
|
||||
.order_by(SupportChatSession.last_msg_at.desc())
|
||||
.paginate(page=page, per_page=25, error_out=False))
|
||||
return render_template('support/admin_conversations.html', sessions=sessions)
|
||||
|
||||
|
||||
@bp.route('/admin/conversations/<int:session_id>')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_conversation_detail(session_id):
|
||||
chat_session = db.session.get(SupportChatSession, session_id)
|
||||
if chat_session is None:
|
||||
abort(404)
|
||||
return render_template('support/conversation_detail.html',
|
||||
chat_session=chat_session,
|
||||
messages=list(chat_session.messages),
|
||||
is_admin=True)
|
||||
|
||||
|
||||
# ── Admin: knowledge base ─────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/admin/knowledge')
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge():
|
||||
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all()
|
||||
return render_template('support/admin_knowledge.html', entries=entries)
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/add', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_add():
|
||||
title = request.form.get('title', '').strip()
|
||||
body = request.form.get('body', '').strip()
|
||||
if not title or not body:
|
||||
flash('Title and body are both required.', 'warning')
|
||||
return redirect(url_for('support.admin_knowledge'))
|
||||
|
||||
entry = SupportKnowledge(
|
||||
title = title,
|
||||
body = body,
|
||||
active = True,
|
||||
created_by = current_user.id,
|
||||
created_at = now_eastern(),
|
||||
updated_at = now_eastern(),
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'SupportKnowledge', entry.id, title[:60], '')
|
||||
flash('Knowledge entry added.', 'success')
|
||||
return redirect(url_for('support.admin_knowledge'))
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/<int:entry_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_edit(entry_id):
|
||||
entry = db.session.get(SupportKnowledge, entry_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
title = request.form.get('title', '').strip()
|
||||
body = request.form.get('body', '').strip()
|
||||
if not title or not body:
|
||||
flash('Title and body are both required.', 'warning')
|
||||
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
|
||||
entry.title = title
|
||||
entry.body = body
|
||||
entry.updated_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
|
||||
flash('Knowledge entry updated.', 'success')
|
||||
return redirect(url_for('support.admin_knowledge'))
|
||||
|
||||
return render_template('support/admin_knowledge_edit.html', entry=entry)
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/<int:entry_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_toggle(entry_id):
|
||||
entry = db.session.get(SupportKnowledge, entry_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
entry.active = not entry.active
|
||||
entry.updated_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60],
|
||||
f'active={entry.active}')
|
||||
flash(f'Entry {"activated" if entry.active else "deactivated"}.', 'success')
|
||||
return redirect(url_for('support.admin_knowledge'))
|
||||
|
||||
|
||||
@bp.route('/admin/knowledge/<int:entry_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def admin_knowledge_delete(entry_id):
|
||||
entry = db.session.get(SupportKnowledge, entry_id)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
label = entry.title[:60]
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
log_action(ACTION_DELETE, 'SupportKnowledge', entry_id, label, '')
|
||||
flash('Knowledge entry deleted.', 'success')
|
||||
return redirect(url_for('support.admin_knowledge'))
|
||||
|
||||
Reference in New Issue
Block a user