Initial commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import logging
|
||||
from flask import request
|
||||
from app import db
|
||||
from app.models import ActivityLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_real_ip():
|
||||
"""
|
||||
Return the genuine client IP address when Flask sits behind an nginx
|
||||
reverse proxy.
|
||||
|
||||
nginx forwards the original client IP in two headers:
|
||||
- X-Forwarded-For: <client>, <proxy1>, <proxy2>, ...
|
||||
- X-Real-IP: <client>
|
||||
|
||||
request.remote_addr is always 127.0.0.1 in a proxied setup, so we
|
||||
must read the headers instead. We take the leftmost (first) value in
|
||||
X-Forwarded-For because that is the original client; subsequent values
|
||||
are intermediate proxies appended by each hop.
|
||||
|
||||
Falls back to X-Real-IP, then remote_addr as a last resort.
|
||||
"""
|
||||
try:
|
||||
xff = request.headers.get('X-Forwarded-For')
|
||||
if xff:
|
||||
# Strip any port appended by some proxies and take first entry
|
||||
return xff.split(',')[0].strip()
|
||||
xri = request.headers.get('X-Real-IP')
|
||||
if xri:
|
||||
return xri.strip()
|
||||
return request.remote_addr
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
|
||||
"""
|
||||
Persist an activity log entry for create / edit / delete actions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id : int | None – the acting user (None for system actions)
|
||||
action : str – e.g. 'ticket_create', 'ticket_update', 'comment_delete'
|
||||
entity_type : str | None – 'ticket', 'comment', 'user', etc.
|
||||
entity_id : int | None – primary key of the affected entity
|
||||
details : str | None – free-form JSON or human-readable details
|
||||
"""
|
||||
ip = _get_real_ip()
|
||||
|
||||
try:
|
||||
entry = ActivityLog(
|
||||
user_id = user_id,
|
||||
action = action,
|
||||
entity_type = entity_type,
|
||||
entity_id = entity_id,
|
||||
details = details,
|
||||
ip_address = ip,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
f'[ACTIVITY] action={action} entity={entity_type}:{entity_id} '
|
||||
f'user_id={user_id} ip={ip}'
|
||||
)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error(f'[ACTIVITY LOG ERROR] {exc}')
|
||||
|
||||
|
||||
def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
|
||||
"""Record a granular field-level change on a ticket."""
|
||||
from app.models import TicketHistory
|
||||
try:
|
||||
entry = TicketHistory(
|
||||
ticket_id = ticket.id,
|
||||
changed_by = changed_by_id,
|
||||
field_name = field_name,
|
||||
old_value = str(old_value) if old_value is not None else None,
|
||||
new_value = str(new_value) if new_value is not None else None,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
f'[TICKET HISTORY] ticket_id={ticket.id} field={field_name} '
|
||||
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
|
||||
)
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error(f'[TICKET HISTORY ERROR] {exc}')
|
||||
@@ -0,0 +1,255 @@
|
||||
import logging
|
||||
from flask import current_app, render_template_string
|
||||
from flask_mail import Message
|
||||
from app import db, mail, socketio
|
||||
from app.models import Notification, NotificationType, User, UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Email Templates ──────────────────────────────────────────────────────────
|
||||
|
||||
_NEW_TICKET_EMAIL = """
|
||||
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
|
||||
<div style="background:#1a1a2e;padding:24px 32px;">
|
||||
<h1 style="color:#e94560;margin:0;font-size:22px;">🎫 New IT Ticket Created</h1>
|
||||
</div>
|
||||
<div style="padding:32px;">
|
||||
<p style="color:#555;margin-top:0;">A new support ticket has been submitted.</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr><td style="padding:8px;color:#888;width:140px;">Ticket #</td><td style="padding:8px;font-weight:bold;">{{ ticket_number }}</td></tr>
|
||||
<tr style="background:#f9f9f9;"><td style="padding:8px;color:#888;">Title</td><td style="padding:8px;">{{ title }}</td></tr>
|
||||
<tr><td style="padding:8px;color:#888;">Category</td><td style="padding:8px;">{{ category }}</td></tr>
|
||||
<tr style="background:#f9f9f9;"><td style="padding:8px;color:#888;">Priority</td><td style="padding:8px;"><span style="background:{{ priority_color }};color:#fff;padding:2px 8px;border-radius:4px;">{{ priority }}</span></td></tr>
|
||||
<tr><td style="padding:8px;color:#888;">Submitted by</td><td style="padding:8px;">{{ submitted_by }}</td></tr>
|
||||
</table>
|
||||
<div style="background:#f9f9f9;border-left:4px solid #e94560;padding:16px;margin:16px 0;border-radius:0 4px 4px 0;">
|
||||
<p style="margin:0;color:#333;">{{ description }}</p>
|
||||
</div>
|
||||
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View Ticket</a>
|
||||
</div>
|
||||
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;color:#999;font-size:12px;">
|
||||
IT Helpdesk System • This is an automated notification.
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
_STATUS_UPDATE_EMAIL = """
|
||||
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.1);">
|
||||
<div style="background:#1a1a2e;padding:24px 32px;">
|
||||
<h1 style="color:#e94560;margin:0;font-size:22px;">🔄 Ticket Update</h1>
|
||||
</div>
|
||||
<div style="padding:32px;">
|
||||
<p style="color:#555;margin-top:0;">Your ticket <strong>{{ ticket_number }}</strong> has been updated.</p>
|
||||
<p><strong>{{ title }}</strong></p>
|
||||
<p style="color:#555;">{{ message }}</p>
|
||||
<a href="{{ ticket_url }}" style="display:inline-block;background:#e94560;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:8px;">View Ticket</a>
|
||||
</div>
|
||||
<div style="background:#f4f4f4;padding:16px 32px;text-align:center;color:#999;font-size:12px;">
|
||||
IT Helpdesk System • This is an automated notification.
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def _priority_badge_color(priority):
|
||||
return {'low': '#28a745', 'medium': '#ffc107', 'high': '#dc3545', 'critical': '#343a40'}.get(priority, '#6c757d')
|
||||
|
||||
|
||||
# ─── In-App Notification ──────────────────────────────────────────────────────
|
||||
|
||||
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
|
||||
"""Persist an in-app notification and push via WebSocket."""
|
||||
try:
|
||||
notif = Notification(
|
||||
user_id = user_id,
|
||||
ticket_id= ticket_id,
|
||||
type = notif_type,
|
||||
title = title,
|
||||
message = message,
|
||||
link = link,
|
||||
)
|
||||
db.session.add(notif)
|
||||
db.session.commit()
|
||||
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
|
||||
|
||||
# Real-time push
|
||||
socketio.emit('new_notification', {
|
||||
'id' : notif.id,
|
||||
'type' : notif_type,
|
||||
'title' : title,
|
||||
'message' : message,
|
||||
'link' : link,
|
||||
'created_at': notif.created_at.isoformat(),
|
||||
}, room=f'user_{user_id}')
|
||||
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
|
||||
|
||||
|
||||
# ─── Email Notification ───────────────────────────────────────────────────────
|
||||
|
||||
def send_email(subject, recipients, html_body):
|
||||
"""Send an email; silently log errors so it never blocks the main flow."""
|
||||
try:
|
||||
msg = Message(subject=subject, recipients=recipients, html=html_body)
|
||||
mail.send(msg)
|
||||
logger.info(f'[EMAIL SENT] subject="{subject}" to={recipients}')
|
||||
except Exception as exc:
|
||||
logger.error(f'[EMAIL ERROR] Failed to send "{subject}" to {recipients}: {exc}')
|
||||
|
||||
|
||||
# ─── Ticket Event Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def notify_new_ticket(ticket):
|
||||
"""Notify IT staff (email + in-app) and confirm receipt to the creator."""
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||
|
||||
# Email IT staff
|
||||
html = render_template_string(_NEW_TICKET_EMAIL,
|
||||
ticket_number = ticket.ticket_number,
|
||||
title = ticket.title,
|
||||
description = ticket.description[:500],
|
||||
category = ticket.category.replace('_', ' ').title(),
|
||||
priority = ticket.priority.upper(),
|
||||
priority_color= _priority_badge_color(ticket.priority),
|
||||
submitted_by = ticket.creator.full_name,
|
||||
ticket_url = ticket_url,
|
||||
)
|
||||
it_email = current_app.config.get('IT_DEPT_EMAIL')
|
||||
send_email(f'[New Ticket] {ticket.ticket_number} – {ticket.title}', [it_email], html)
|
||||
|
||||
# In-app: all IT staff
|
||||
it_users = User.query.filter(
|
||||
User.role.in_([UserRole.IT_STAFF, UserRole.ADMIN]),
|
||||
User.is_active == True,
|
||||
).all()
|
||||
for staff in it_users:
|
||||
create_notification(
|
||||
user_id = staff.id,
|
||||
notif_type= NotificationType.TICKET_CREATED,
|
||||
title = f'New Ticket: {ticket.ticket_number}',
|
||||
message = f'{ticket.creator.full_name} submitted: {ticket.title}',
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}',
|
||||
)
|
||||
|
||||
# Confirm to creator
|
||||
create_notification(
|
||||
user_id = ticket.created_by_id,
|
||||
notif_type= NotificationType.TICKET_CREATED,
|
||||
title = f'Ticket {ticket.ticket_number} Created',
|
||||
message = 'Your ticket has been received. Our IT team will review it shortly.',
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}',
|
||||
)
|
||||
|
||||
|
||||
def notify_status_change(ticket, old_status, changed_by):
|
||||
"""Notify ticket creator of status change."""
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||
msg_text = f'Status changed from {old_status.replace("_"," ").title()} to {ticket.status.replace("_"," ").title()} by {changed_by.full_name}.'
|
||||
|
||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||
ticket_number = ticket.ticket_number,
|
||||
title = ticket.title,
|
||||
message = msg_text,
|
||||
ticket_url = ticket_url,
|
||||
)
|
||||
if ticket.creator.email_notif:
|
||||
send_email(
|
||||
f'[Ticket Update] {ticket.ticket_number} – Status Changed',
|
||||
[ticket.creator.email],
|
||||
html,
|
||||
)
|
||||
create_notification(
|
||||
user_id = ticket.created_by_id,
|
||||
notif_type= NotificationType.STATUS_CHANGED,
|
||||
title = f'Ticket {ticket.ticket_number} Updated',
|
||||
message = msg_text,
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}',
|
||||
)
|
||||
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
|
||||
|
||||
|
||||
def notify_comment_added(comment):
|
||||
"""Notify relevant parties when a comment is added."""
|
||||
ticket = comment.ticket
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||
|
||||
notified = set()
|
||||
|
||||
def _notify(user_id, is_internal=False):
|
||||
if user_id in notified:
|
||||
return
|
||||
notified.add(user_id)
|
||||
create_notification(
|
||||
user_id = user_id,
|
||||
notif_type= NotificationType.COMMENT_ADDED,
|
||||
title = f'New Comment on {ticket.ticket_number}',
|
||||
message = f'{comment.author.full_name} commented: {comment.body[:100]}',
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}#comment-{comment.id}',
|
||||
)
|
||||
user = User.query.get(user_id)
|
||||
if user and user.email_notif and not is_internal:
|
||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||
ticket_number = ticket.ticket_number,
|
||||
title = ticket.title,
|
||||
message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
|
||||
ticket_url = ticket_url,
|
||||
)
|
||||
send_email(
|
||||
f'[Ticket Comment] {ticket.ticket_number}',
|
||||
[user.email],
|
||||
html,
|
||||
)
|
||||
|
||||
# Notify creator (skip if internal-only comment)
|
||||
if not comment.is_internal:
|
||||
_notify(ticket.created_by_id)
|
||||
|
||||
# Notify assignee
|
||||
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
|
||||
_notify(ticket.assigned_to_id, is_internal=comment.is_internal)
|
||||
|
||||
logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}')
|
||||
|
||||
|
||||
def notify_assignment(ticket, assigned_by):
|
||||
"""Notify newly assigned IT staff member."""
|
||||
if not ticket.assigned_to_id:
|
||||
return
|
||||
base_url = current_app.config.get('APP_BASE_URL', '')
|
||||
ticket_url = f"{base_url}/tickets/{ticket.id}"
|
||||
|
||||
create_notification(
|
||||
user_id = ticket.assigned_to_id,
|
||||
notif_type= NotificationType.TICKET_ASSIGNED,
|
||||
title = f'Ticket Assigned: {ticket.ticket_number}',
|
||||
message = f'You have been assigned ticket "{ticket.title}" by {assigned_by.full_name}.',
|
||||
ticket_id = ticket.id,
|
||||
link = f'/tickets/{ticket.id}',
|
||||
)
|
||||
if ticket.assignee and ticket.assignee.email_notif:
|
||||
html = render_template_string(_STATUS_UPDATE_EMAIL,
|
||||
ticket_number = ticket.ticket_number,
|
||||
title = ticket.title,
|
||||
message = f'This ticket has been assigned to you by {assigned_by.full_name}.',
|
||||
ticket_url = ticket_url,
|
||||
)
|
||||
send_email(
|
||||
f'[Ticket Assigned] {ticket.ticket_number}',
|
||||
[ticket.assignee.email],
|
||||
html,
|
||||
)
|
||||
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
|
||||
Reference in New Issue
Block a user