Files
2026-03-27 14:01:11 -04:00

133 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
Transaction note
----------------
This function deliberately does NOT call db.session.commit(). The entry
is added to the current session and committed by the caller alongside its
own business objects. This ensures the log entry is only persisted when
the parent operation succeeds — an independent commit here would leave
orphaned log entries for operations that were subsequently rolled back.
Error isolation
---------------
On failure, only the log entry itself is expelled from the session via
expunge(). db.session.rollback() is intentionally NOT called here because
that would wipe the entire session — silently undoing the parent business
operation (ticket creation, user update, etc.) that triggered this log call.
"""
ip = _get_real_ip()
entry = None
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)
# flush to surface constraint violations early without committing
db.session.flush()
logger.info(
f'[ACTIVITY] action={action} entity={entity_type}:{entity_id} '
f'user_id={user_id} ip={ip}'
)
except Exception as exc:
# Expunge only the failed log entry — do NOT roll back the full session,
# as that would undo the parent operation that called this function.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[ACTIVITY LOG ERROR] action={action} entity={entity_type}:{entity_id} 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.
Transaction note
----------------
Like log_action, this function does NOT commit — the caller is responsible
for committing the session after all field changes have been recorded.
Error isolation
---------------
On failure, only the failed history entry is expelled from the session.
db.session.rollback() is intentionally NOT called here — that would undo
the parent ticket update that triggered this history recording.
"""
from app.models import TicketHistory
entry = None
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.flush()
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:
# Expunge only the failed history entry — do NOT roll back the full
# session, as that would undo the parent ticket update.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[TICKET HISTORY ERROR] ticket_id={ticket.id} field={field_name} error={exc}')