Skip to content

Commit fe16cb4

Browse files
authored
Indicate deleted actors and projects in Events API (#3422)
In `/api/events/list`: - Instead of `_deleted_*` placeholders, return original names of deleted projects and actors - Add the `is_project_deleted` and `is_actor_user_deleted` properties to indicate deleted projects and actors
1 parent fc26a0d commit fe16cb4

3 files changed

Lines changed: 88 additions & 14 deletions

File tree

src/dstack/_internal/core/models/events.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ class EventTarget(CoreModel):
4646
)
4747
),
4848
]
49+
is_project_deleted: Annotated[
50+
Optional[bool],
51+
Field(
52+
description=(
53+
"Whether the project the target entity belongs to is deleted,"
54+
" or `null` for target types not bound to a project (e.g., users)"
55+
)
56+
),
57+
] = None # default for client compatibility with pre-0.20.1 servers
4958
id: Annotated[uuid.UUID, Field(description="ID of the target entity")]
5059
name: Annotated[str, Field(description="Name of the target entity")]
5160

@@ -72,6 +81,15 @@ class Event(CoreModel):
7281
)
7382
),
7483
]
84+
is_actor_user_deleted: Annotated[
85+
Optional[bool],
86+
Field(
87+
description=(
88+
"Whether the user who performed the action that triggered the event is deleted,"
89+
" or `null` if the action was performed by the system"
90+
)
91+
),
92+
] = None # default for client compatibility with pre-0.20.1 servers
7593
targets: Annotated[
7694
list[EventTarget], Field(description="List of entities affected by the event")
7795
]

src/dstack/_internal/server/services/events.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -364,10 +364,12 @@ async def list_events(
364364
(
365365
joinedload(EventModel.targets)
366366
.joinedload(EventTargetModel.entity_project)
367-
.load_only(ProjectModel.name)
367+
.load_only(ProjectModel.name, ProjectModel.original_name, ProjectModel.deleted)
368368
.noload(ProjectModel.owner)
369369
),
370-
joinedload(EventModel.actor_user).load_only(UserModel.name),
370+
joinedload(EventModel.actor_user).load_only(
371+
UserModel.name, UserModel.original_name, UserModel.deleted
372+
),
371373
)
372374
)
373375
if event_filters:
@@ -386,23 +388,39 @@ async def list_events(
386388
return list(map(event_model_to_event, event_models))
387389

388390

389-
def event_model_to_event(event_model: EventModel) -> Event:
390-
targets = [
391-
EventTarget(
392-
type=target.entity_type.value,
393-
project_id=target.entity_project_id,
394-
project_name=target.entity_project.name if target.entity_project else None,
395-
id=target.entity_id,
396-
name=target.entity_name,
397-
)
398-
for target in event_model.targets
399-
]
391+
def event_target_model_to_event_target(model: EventTargetModel) -> EventTarget:
392+
project_name = None
393+
is_project_deleted = None
394+
if model.entity_project is not None:
395+
project_name = model.entity_project.name
396+
is_project_deleted = model.entity_project.deleted
397+
if is_project_deleted and model.entity_project.original_name is not None:
398+
project_name = model.entity_project.original_name
399+
return EventTarget(
400+
type=model.entity_type.value,
401+
project_id=model.entity_project_id,
402+
project_name=project_name,
403+
is_project_deleted=is_project_deleted,
404+
id=model.entity_id,
405+
name=model.entity_name,
406+
)
407+
400408

409+
def event_model_to_event(event_model: EventModel) -> Event:
410+
actor_user_name = None
411+
is_actor_user_deleted = None
412+
if event_model.actor_user is not None:
413+
actor_user_name = event_model.actor_user.name
414+
is_actor_user_deleted = event_model.actor_user.deleted
415+
if is_actor_user_deleted and event_model.actor_user.original_name is not None:
416+
actor_user_name = event_model.actor_user.original_name
417+
targets = list(map(event_target_model_to_event_target, event_model.targets))
401418
return Event(
402419
id=event_model.id,
403420
message=event_model.message,
404421
recorded_at=event_model.recorded_at,
405422
actor_user_id=event_model.actor_user_id,
406-
actor_user=event_model.actor_user.name if event_model.actor_user else None,
423+
actor_user=actor_user_name,
424+
is_actor_user_deleted=is_actor_user_deleted,
407425
targets=targets,
408426
)

src/tests/_internal/server/routers/test_events.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,13 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient)
6868
"recorded_at": "2026-01-01T12:00:01+00:00",
6969
"actor_user_id": None,
7070
"actor_user": None,
71+
"is_actor_user_deleted": None,
7172
"targets": [
7273
{
7374
"type": "project",
7475
"project_id": str(project.id),
7576
"project_name": "test_project",
77+
"is_project_deleted": False,
7678
"id": str(project.id),
7779
"name": "test_project",
7880
},
@@ -84,25 +86,61 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient)
8486
"recorded_at": "2026-01-01T12:00:00+00:00",
8587
"actor_user_id": str(user.id),
8688
"actor_user": "test_user",
89+
"is_actor_user_deleted": False,
8790
"targets": [
8891
{
8992
"type": "project",
9093
"project_id": str(project.id),
9194
"project_name": "test_project",
95+
"is_project_deleted": False,
9296
"id": str(project.id),
9397
"name": "test_project",
9498
},
9599
{
96100
"type": "user",
97101
"project_id": None,
98102
"project_name": None,
103+
"is_project_deleted": None,
99104
"id": str(user.id),
100105
"name": "test_user",
101106
},
102107
],
103108
},
104109
]
105110

111+
async def test_deleted_actor_and_project(
112+
self, session: AsyncSession, client: AsyncClient
113+
) -> None:
114+
user = await create_user(session=session, name="test_user")
115+
project = await create_project(session=session, owner=user, name="test_project")
116+
events.emit(
117+
session,
118+
"Project deleted",
119+
actor=events.UserActor.from_user(user),
120+
targets=[events.Target.from_model(project)],
121+
)
122+
user.original_name = user.name
123+
user.name = "_deleted_user_placeholder"
124+
user.deleted = True
125+
project.original_name = project.name
126+
project.name = "_deleted_project_placeholder"
127+
project.deleted = True
128+
await session.commit()
129+
other_user = await create_user(session=session, name="other_user")
130+
131+
resp = await client.post(
132+
"/api/events/list", headers=get_auth_headers(other_user.token), json={}
133+
)
134+
resp.raise_for_status()
135+
assert len(resp.json()) == 1
136+
assert resp.json()[0]["actor_user_id"] == str(user.id)
137+
assert resp.json()[0]["actor_user"] == "test_user"
138+
assert resp.json()[0]["is_actor_user_deleted"] == True
139+
assert len(resp.json()[0]["targets"]) == 1
140+
assert resp.json()[0]["targets"][0]["project_id"] == str(project.id)
141+
assert resp.json()[0]["targets"][0]["project_name"] == "test_project"
142+
assert resp.json()[0]["targets"][0]["is_project_deleted"] == True
143+
106144
async def test_empty_response_when_no_events(
107145
self, session: AsyncSession, client: AsyncClient
108146
) -> None:

0 commit comments

Comments
 (0)