fix(06.2): audit log — add email column, remove @ prefix from handles
- Backend: add user_email to _build_filtered_query_with_handles (UserSubject join) and _audit_to_dict_with_handles; propagate through JSON viewer and CSV export including empty-result path - Frontend: AuditLogTab adds Email column between User and Action Type; removes @ prefix from handle cell - Test: update expected CSV header to include user_email Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Timestamp</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">User</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Email</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Action Type</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
|
||||
</tr>
|
||||
@@ -107,7 +108,8 @@
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle ? '@' + entry.user_handle : (entry.user_id || '—') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ entry.user_email || '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||
|
||||
Reference in New Issue
Block a user