From 2005dc5b1d080048722ae14ebd7e94fc3a70ec9e Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Wed, 24 Dec 2025 21:58:59 +0100 Subject: [PATCH 1/2] Indicate deleted actors and projects in Events API 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 --- src/dstack/_internal/core/models/events.py | 18 ++++++++ .../_internal/server/services/events.py | 46 +++++++++++++------ .../_internal/server/routers/test_events.py | 38 +++++++++++++++ 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/dstack/_internal/core/models/events.py b/src/dstack/_internal/core/models/events.py index caf6d60e47..84c569172e 100644 --- a/src/dstack/_internal/core/models/events.py +++ b/src/dstack/_internal/core/models/events.py @@ -46,6 +46,15 @@ class EventTarget(CoreModel): ) ), ] + is_project_deleted: Annotated[ + Optional[bool], + Field( + description=( + "Whether the project the target entity belongs to is deleted," + " or `null` for target types not bound to a project (e.g., users)" + ) + ), + ] = None # default for client compatibility with older servers that don't return this field id: Annotated[uuid.UUID, Field(description="ID of the target entity")] name: Annotated[str, Field(description="Name of the target entity")] @@ -72,6 +81,15 @@ class Event(CoreModel): ) ), ] + is_actor_user_deleted: Annotated[ + Optional[bool], + Field( + description=( + "Whether the user who performed the action that triggered the event is deleted," + " or `null` if the action was performed by the system" + ) + ), + ] = None # default for client compatibility with older servers that don't return this field targets: Annotated[ list[EventTarget], Field(description="List of entities affected by the event") ] diff --git a/src/dstack/_internal/server/services/events.py b/src/dstack/_internal/server/services/events.py index 7a4d355237..c9818ef9ee 100644 --- a/src/dstack/_internal/server/services/events.py +++ b/src/dstack/_internal/server/services/events.py @@ -364,10 +364,12 @@ async def list_events( ( joinedload(EventModel.targets) .joinedload(EventTargetModel.entity_project) - .load_only(ProjectModel.name) + .load_only(ProjectModel.name, ProjectModel.original_name, ProjectModel.deleted) .noload(ProjectModel.owner) ), - joinedload(EventModel.actor_user).load_only(UserModel.name), + joinedload(EventModel.actor_user).load_only( + UserModel.name, UserModel.original_name, UserModel.deleted + ), ) ) if event_filters: @@ -386,23 +388,39 @@ async def list_events( return list(map(event_model_to_event, event_models)) -def event_model_to_event(event_model: EventModel) -> Event: - targets = [ - EventTarget( - type=target.entity_type.value, - project_id=target.entity_project_id, - project_name=target.entity_project.name if target.entity_project else None, - id=target.entity_id, - name=target.entity_name, - ) - for target in event_model.targets - ] +def event_target_model_to_event_target(model: EventTargetModel) -> EventTarget: + project_name = None + is_project_deleted = None + if model.entity_project is not None: + project_name = model.entity_project.name + is_project_deleted = model.entity_project.deleted + if is_project_deleted and model.entity_project.original_name is not None: + project_name = model.entity_project.original_name + return EventTarget( + type=model.entity_type.value, + project_id=model.entity_project_id, + project_name=project_name, + is_project_deleted=is_project_deleted, + id=model.entity_id, + name=model.entity_name, + ) + +def event_model_to_event(event_model: EventModel) -> Event: + actor_user_name = None + is_actor_user_deleted = None + if event_model.actor_user is not None: + actor_user_name = event_model.actor_user.name + is_actor_user_deleted = event_model.actor_user.deleted + if is_actor_user_deleted and event_model.actor_user.original_name is not None: + actor_user_name = event_model.actor_user.original_name + targets = list(map(event_target_model_to_event_target, event_model.targets)) return Event( id=event_model.id, message=event_model.message, recorded_at=event_model.recorded_at, actor_user_id=event_model.actor_user_id, - actor_user=event_model.actor_user.name if event_model.actor_user else None, + actor_user=actor_user_name, + is_actor_user_deleted=is_actor_user_deleted, targets=targets, ) diff --git a/src/tests/_internal/server/routers/test_events.py b/src/tests/_internal/server/routers/test_events.py index 478474bca7..f31c082d06 100644 --- a/src/tests/_internal/server/routers/test_events.py +++ b/src/tests/_internal/server/routers/test_events.py @@ -68,11 +68,13 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) "recorded_at": "2026-01-01T12:00:01+00:00", "actor_user_id": None, "actor_user": None, + "is_actor_user_deleted": None, "targets": [ { "type": "project", "project_id": str(project.id), "project_name": "test_project", + "is_project_deleted": False, "id": str(project.id), "name": "test_project", }, @@ -84,11 +86,13 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) "recorded_at": "2026-01-01T12:00:00+00:00", "actor_user_id": str(user.id), "actor_user": "test_user", + "is_actor_user_deleted": False, "targets": [ { "type": "project", "project_id": str(project.id), "project_name": "test_project", + "is_project_deleted": False, "id": str(project.id), "name": "test_project", }, @@ -96,6 +100,7 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) "type": "user", "project_id": None, "project_name": None, + "is_project_deleted": None, "id": str(user.id), "name": "test_user", }, @@ -103,6 +108,39 @@ async def test_response_format(self, session: AsyncSession, client: AsyncClient) }, ] + async def test_deleted_actor_and_project( + self, session: AsyncSession, client: AsyncClient + ) -> None: + user = await create_user(session=session, name="test_user") + project = await create_project(session=session, owner=user, name="test_project") + events.emit( + session, + "Project deleted", + actor=events.UserActor.from_user(user), + targets=[events.Target.from_model(project)], + ) + user.original_name = user.name + user.name = "_deleted_user_placeholder" + user.deleted = True + project.original_name = project.name + project.name = "_deleted_project_placeholder" + project.deleted = True + await session.commit() + other_user = await create_user(session=session, name="other_user") + + resp = await client.post( + "/api/events/list", headers=get_auth_headers(other_user.token), json={} + ) + resp.raise_for_status() + assert len(resp.json()) == 1 + assert resp.json()[0]["actor_user_id"] == str(user.id) + assert resp.json()[0]["actor_user"] == "test_user" + assert resp.json()[0]["is_actor_user_deleted"] == True + assert len(resp.json()[0]["targets"]) == 1 + assert resp.json()[0]["targets"][0]["project_id"] == str(project.id) + assert resp.json()[0]["targets"][0]["project_name"] == "test_project" + assert resp.json()[0]["targets"][0]["is_project_deleted"] == True + async def test_empty_response_when_no_events( self, session: AsyncSession, client: AsyncClient ) -> None: From eb831277ba24df5e9c566be7d11eb1aece281cd2 Mon Sep 17 00:00:00 2001 From: Jvst Me Date: Thu, 25 Dec 2025 09:42:56 +0100 Subject: [PATCH 2/2] Update compatibility comments --- src/dstack/_internal/core/models/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dstack/_internal/core/models/events.py b/src/dstack/_internal/core/models/events.py index 84c569172e..fc7f51601a 100644 --- a/src/dstack/_internal/core/models/events.py +++ b/src/dstack/_internal/core/models/events.py @@ -54,7 +54,7 @@ class EventTarget(CoreModel): " or `null` for target types not bound to a project (e.g., users)" ) ), - ] = None # default for client compatibility with older servers that don't return this field + ] = None # default for client compatibility with pre-0.20.1 servers id: Annotated[uuid.UUID, Field(description="ID of the target entity")] name: Annotated[str, Field(description="Name of the target entity")] @@ -89,7 +89,7 @@ class Event(CoreModel): " or `null` if the action was performed by the system" ) ), - ] = None # default for client compatibility with older servers that don't return this field + ] = None # default for client compatibility with pre-0.20.1 servers targets: Annotated[ list[EventTarget], Field(description="List of entities affected by the event") ]