diff --git a/backend/api/audit.py b/backend/api/audit.py index 9e2b9e9..91778b6 100644 --- a/backend/api/audit.py +++ b/backend/api/audit.py @@ -69,12 +69,14 @@ def _audit_to_dict_with_handles( entry: AuditLog, user_handle: Optional[str], actor_handle: Optional[str], + user_email: Optional[str] = None, ) -> dict: - """Extended audit log serializer that includes user_handle and actor_handle. + """Extended audit log serializer that includes user_handle, actor_handle, and user_email. Returns the same fields as _audit_to_dict() plus: - user_handle: str | None (the handle of the user who owns the entry) - actor_handle: str | None (the handle of the actor who performed the event) + - user_email: str | None (the email of the user who owns the entry) Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both endpoints must use the enriched function). @@ -86,6 +88,7 @@ def _audit_to_dict_with_handles( "actor_id": str(entry.actor_id) if entry.actor_id else None, "user_handle": user_handle or None, "actor_handle": actor_handle or None, + "user_email": user_email or None, "resource_id": str(entry.resource_id) if entry.resource_id else None, "ip_address": str(entry.ip_address) if entry.ip_address else None, "metadata_": entry.metadata_, @@ -145,6 +148,7 @@ def _build_filtered_query_with_handles( AuditLog, UserSubject.handle.label("user_handle"), UserActor.handle.label("actor_handle"), + UserSubject.email.label("user_email"), ) .outerjoin(UserSubject, UserSubject.id == AuditLog.user_id) .outerjoin(UserActor, UserActor.id == AuditLog.actor_id) @@ -296,8 +300,8 @@ async def list_audit_log( items = [] for row in rows: - entry, user_handle_val, actor_handle_val = row[0], row[1], row[2] - items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)) + entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3] + items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val)) return { "items": items, @@ -339,7 +343,7 @@ async def export_audit_log( empty_output = io.StringIO() fields = [ "id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle", - "resource_id", "ip_address", "metadata_", "created_at", + "user_email", "resource_id", "ip_address", "metadata_", "created_at", ] writer = csv.DictWriter(empty_output, fieldnames=fields) writer.writeheader() @@ -362,6 +366,7 @@ async def export_audit_log( "actor_id", "user_handle", "actor_handle", + "user_email", "resource_id", "ip_address", "metadata_", @@ -371,8 +376,8 @@ async def export_audit_log( writer = csv.DictWriter(output, fieldnames=fields) writer.writeheader() for row in rows: - entry, user_handle_val, actor_handle_val = row[0], row[1], row[2] - record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val) + entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3] + record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val) record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else "" writer.writerow(record) diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 2b0e59e..6c99954 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -181,7 +181,7 @@ async def test_audit_log_export_csv(async_client, admin_user, db_session): # Phase 6.2: CSV now includes user_handle and actor_handle columns (D-11, Pitfall 7) expected_header = ( "id,event_type,user_id,actor_id,user_handle,actor_handle," - "resource_id,ip_address,metadata_,created_at" + "user_email,resource_id,ip_address,metadata_,created_at" ) assert expected_header in response.text, ( f"CSV header line not found in response. " diff --git a/frontend/src/components/admin/AuditLogTab.vue b/frontend/src/components/admin/AuditLogTab.vue index c0cac67..2ba7eec 100644 --- a/frontend/src/components/admin/AuditLogTab.vue +++ b/frontend/src/components/admin/AuditLogTab.vue @@ -96,6 +96,7 @@