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:
curo1305
2026-06-01 20:59:09 +02:00
parent d771f0805d
commit 7027347597
3 changed files with 15 additions and 8 deletions
+11 -6
View File
@@ -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)
+1 -1
View File
@@ -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"