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: , , , ... - X-Real-IP: 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}')