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,
|
entry: AuditLog,
|
||||||
user_handle: Optional[str],
|
user_handle: Optional[str],
|
||||||
actor_handle: Optional[str],
|
actor_handle: Optional[str],
|
||||||
|
user_email: Optional[str] = None,
|
||||||
) -> dict:
|
) -> 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:
|
Returns the same fields as _audit_to_dict() plus:
|
||||||
- user_handle: str | None (the handle of the user who owns the entry)
|
- 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)
|
- 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
|
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
|
||||||
endpoints must use the enriched function).
|
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,
|
"actor_id": str(entry.actor_id) if entry.actor_id else None,
|
||||||
"user_handle": user_handle or None,
|
"user_handle": user_handle or None,
|
||||||
"actor_handle": actor_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,
|
"resource_id": str(entry.resource_id) if entry.resource_id else None,
|
||||||
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
||||||
"metadata_": entry.metadata_,
|
"metadata_": entry.metadata_,
|
||||||
@@ -145,6 +148,7 @@ def _build_filtered_query_with_handles(
|
|||||||
AuditLog,
|
AuditLog,
|
||||||
UserSubject.handle.label("user_handle"),
|
UserSubject.handle.label("user_handle"),
|
||||||
UserActor.handle.label("actor_handle"),
|
UserActor.handle.label("actor_handle"),
|
||||||
|
UserSubject.email.label("user_email"),
|
||||||
)
|
)
|
||||||
.outerjoin(UserSubject, UserSubject.id == AuditLog.user_id)
|
.outerjoin(UserSubject, UserSubject.id == AuditLog.user_id)
|
||||||
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
|
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
|
||||||
@@ -296,8 +300,8 @@ async def list_audit_log(
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
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))
|
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -339,7 +343,7 @@ async def export_audit_log(
|
|||||||
empty_output = io.StringIO()
|
empty_output = io.StringIO()
|
||||||
fields = [
|
fields = [
|
||||||
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
|
"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 = csv.DictWriter(empty_output, fieldnames=fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
@@ -362,6 +366,7 @@ async def export_audit_log(
|
|||||||
"actor_id",
|
"actor_id",
|
||||||
"user_handle",
|
"user_handle",
|
||||||
"actor_handle",
|
"actor_handle",
|
||||||
|
"user_email",
|
||||||
"resource_id",
|
"resource_id",
|
||||||
"ip_address",
|
"ip_address",
|
||||||
"metadata_",
|
"metadata_",
|
||||||
@@ -371,8 +376,8 @@ async def export_audit_log(
|
|||||||
writer = csv.DictWriter(output, fieldnames=fields)
|
writer = csv.DictWriter(output, fieldnames=fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
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)
|
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 ""
|
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
||||||
writer.writerow(record)
|
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)
|
# Phase 6.2: CSV now includes user_handle and actor_handle columns (D-11, Pitfall 7)
|
||||||
expected_header = (
|
expected_header = (
|
||||||
"id,event_type,user_id,actor_id,user_handle,actor_handle,"
|
"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, (
|
assert expected_header in response.text, (
|
||||||
f"CSV header line not found in response. "
|
f"CSV header line not found in response. "
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
<tr class="bg-gray-50 border-b border-gray-200">
|
<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">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">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">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>
|
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
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 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">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-1 rounded-full font-medium"
|
class="text-xs px-2 py-1 rounded-full font-medium"
|
||||||
|
|||||||
Reference in New Issue
Block a user