106 lines
3.8 KiB
Python
106 lines
3.8 KiB
Python
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.
|
||
"""
|
||
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)
|
||
# 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:
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
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.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:
|
||
db.session.rollback()
|
||
logger.error(f'[TICKET HISTORY ERROR] {exc}') |