Skip to content

Commit 24d477c

Browse files
abhizipstackclaude
andauthored
feat: user-facing activity logs with 19 curated events (#66)
* feat: user-facing activity logs — 3 core model events Introduces a UserLevel event tier and 3 curated, plain-language events that replace developer-internal log dumps for model execution: Building model "mmodela" → testing.mmodela as TABLE from "testing.country" Model "mmodela" built successfully in 0.42s Model "mmodela" failed: Table Not Found … Backend - base_types.py: UserLevel class with audience()="user"; BaseEvent gets a default audience()="developer" so all existing events are backward-compatible. - proto_types.py: ModelRunStarted, ModelRunSucceeded, ModelRunFailed message types. - types.py: 3 event classes (U001-U003) inheriting UserLevel. - log_helper.py: LogHelper.log() accepts audience param, included in the socket payload dict. - eventmgr.py: write_line reads audience from the event and passes through to LogHelper. - visitran.py: fires the 3 events from execute_graph — started before run_model, succeeded after, failed in both exception handlers. Frontend - Socket handler reads data?.data?.audience alongside level/message. - logsInfo entries now carry { level, message, audience }. - New "User activity" option at the top of the log-level dropdown (default). Filters to audience==="user" only. - Existing options (All logs, Info+, Warn+, Error) continue to show developer logs regardless of audience. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: CreateConnection crash — hasDetailsChanged used before declaration useMemo for hasDetailsChanged was declared at line 411 but referenced by the handleCreateOrUpdate useCallback at line 148. JavaScript's temporal dead zone (TDZ) caused a ReferenceError on render. Moved the useMemo above the useCallback that depends on it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: style user-activity logs with visual distinction User-audience log entries now render with: - A primary-colored left border (3px, token.colorPrimary) - Subtle background fill (token.colorFillQuaternary) - Slightly bolder font (500 weight, 13px) - Error-colored text for failed events - No HTML parsing (user messages are plain text, no ANSI) Developer logs continue rendering with the existing ANSI-parsed style and severity-based coloring. The visual contrast makes it immediately clear which entries are user-facing activity messages vs developer-internal noise. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename event classes to match proto Msg naming convention msg_from_base_event builds the Msg class name as {ClassName}Msg. Our events were named ModelRunStartedEvent → looked for ModelRunStartedEventMsg which doesn't exist. Dropped the "Event" suffix so ModelRunStarted → ModelRunStartedMsg matches proto_types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add P1 user-facing events — transformations, config, seeds 4 new UserLevel events covering the core model-tab operations: - TransformationApplied (U004): fired after set_model_transformation succeeds. Message: 'Applied sort transformation on "mdoela"' - TransformationDeleted (U005): fired after delete_model_transformation. Message: 'Removed filter transformation from "mdoela"' - ModelConfigured (U006): fired after set_model_config_and_reference. Message: 'Configured "mdoela" — source: raw.customers, destination: analytics.dim_customers' - SeedCompleted (U007): fired after successful/failed seed execution. Message: 'Seed "raw_customers" loaded into "raw"' or 'Seed "raw_customers" failed in "raw"' These fire through the existing UserLevel → LogHelper(audience="user") → Celery → socket pipeline. Frontend filters them via the "User activity" dropdown option. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add P2 user-facing events — job scheduler operations 4 new UserLevel events for job lifecycle actions: - JobCreated (U008): fired after create_periodic_task succeeds. Message: 'Job "Nightly refresh" created for environment "prod"' - JobUpdated (U009): fired after update_periodic_task. Message: 'Job "Nightly refresh" updated' - JobDeleted (U010): fired after delete_periodic_task. Message: 'Job "Nightly refresh" deleted' - JobTriggered (U011): fired from _dispatch_task_run (covers both trigger_task_once and trigger_task_once_for_model). Message: 'Job "Nightly refresh" triggered manually — running all models' or '— running model mdoela' Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add P3-P5 user-facing events — CRUD, connections, environments 8 new UserLevel events covering model/file CRUD, connections, and environments: P3 — Model/project CRUD: - ModelCreated (U012): model created in explorer - FileDeleted (U013): files/models deleted - FileRenamed (U014): file/model renamed P4 — Connections: - ConnectionCreated (U015): new connection created - ConnectionTested (U016): connection test result - ConnectionDeletedEvt (U017): connection deleted P5 — Environments: - EnvironmentCreated (U018): new environment created - EnvironmentDeleted (U019): environment deleted All fire through the UserLevel → audience="user" pipeline and appear in the "User activity" log view. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: show names instead of IDs in connection/environment delete events ConnectionDeletedEvt and EnvironmentDeleted were passing raw UUIDs. Now fetch the name before deleting so the user-activity log shows 'Connection "my_postgres" deleted' instead of 'Connection "uuid" deleted'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: environment delete shows proper error when used by a job Backend returned {"message": "..."} on ProtectedError but the frontend notification service reads "error_message". Changed key to "error_message" for consistency with other endpoints. Also improved the error message copy and added explicit error_message extraction in the frontend catch block so the user sees: "Cannot delete this environment because it is used by: Nightly from 'Deploy'. Remove it from the job first, then delete." instead of the generic "Something went wrong". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — name lookup safety, seed schema, field names P1: Connection name-lookup before delete could block deletion on transient errors. Wrapped in try/except so deletion proceeds regardless; event falls back to connection_id. P2: SeedCompleted failure path had schema_name="". Now reads from self.context.schema_name with fallback to empty string. P2: Renamed misleading proto fields — connection_id → connection_name and environment_id → environment_name since they carry display names, not UUIDs. Updated event classes and all callers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restructure delete_connection — fetch + delete in same try block If fetching the connection fails (doesn't exist, DB down), no point attempting deletion. Both operations now live in one try block. fire_event fires from both success and exception paths so the activity log captures the attempt either way. Exception re-raises so handle_http_request returns the proper error to the frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: raise ConnectionDeleteFailed exception with proper formatting Replaces bare re-raise with a dedicated ConnectionDeleteFailed exception following the same pattern as EnvironmentInUse — BackendErrorMessages template with markdown formatting, caught by handle_http_request decorator for uniform error response. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: separate success and failure events for connection delete Success fires ConnectionDeletedEvt (U017, UserLevel/info): 'Connection "my_postgres" deleted' Failure fires ConnectionDeleteFailedEvt (U020, UserLevel/error): 'Failed to delete connection "my_postgres": reason...' The failure event uses level_tag=ERROR so it renders in red in the activity log, clearly distinguishing it from a successful delete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move success fire_event outside try to prevent false failure fire_event inside try could throw (e.g., logger error) after delete_connection succeeded, triggering the except block and raising ConnectionDeleteFailed for a successful deletion. Moved success event after the try block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fire JobTriggered after successful dispatch, not before JobTriggered was fired optimistically before send_task/sync execution. If both dispatch paths failed, the activity log showed "triggered" for a job that never ran. Moved fire_event to after each successful dispatch, consistent with all other events in this PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: improve activity log readability and celery log queue - Use materialization .name instead of .value so logs show "TABLE"/"VIEW" instead of integer values like "1"/"2" - Extract transformation type from step_config (where frontend sends it) instead of top-level request data, fixing "Applied unknown transformation" - Add celery_log_task_queue to docker-compose celery worker so activity log events are actually consumed and delivered via WebSocket Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: structured activity feed for user-facing logs Replace raw text logs with structured data for user-level events: - Send title, subtitle, status, timestamp as separate fields - Render as activity cards with status icons and color-coded borders - Remove [ThreadPool] prefix from developer logs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: preserve specific exception status codes in connection delete Re-raise VisitranBackendBaseException subclasses (ConnectionNotExists 404, ConnectionDependencyError 409) directly instead of wrapping all errors as ConnectionDeleteFailed 400. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reset pagination to page 1 on job switch in run history Explicitly pass page 1 when switching jobs to prevent fetching a stale page number from the previous job selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rename status() to event_status() to avoid proto field shadowing SeedCompleted has a proto field named 'status' which shadows the method. Renamed to event_status() across all UserLevel events and the base class to prevent runtime errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bfb1f94 commit 24d477c

16 files changed

Lines changed: 878 additions & 53 deletions

File tree

backend/backend/core/routers/connection/views.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from backend.core.utils import handle_http_request
1111
from backend.utils.constants import HTTPMethods
1212
from rbac.factory import handle_permission
13+
from visitran.events.functions import fire_event
14+
from visitran.events.types import ConnectionCreated, ConnectionTested, ConnectionDeletedEvt, ConnectionDeleteFailedEvt
1315

1416
RESOURCE_NAME = "connectiondetails"
1517

@@ -42,6 +44,10 @@ def create_connection(request: Request) -> Response:
4244
connection_data = con_context.create_connection(
4345
connection_details=request_payload, force_create=bool(force_create)
4446
)
47+
fire_event(ConnectionCreated(
48+
connection_name=request_payload.get("name", ""),
49+
datasource=request_payload.get("datasource_name", ""),
50+
))
4551
response_data = {"status": "success", "data": connection_data}
4652
return Response(data=response_data, status=status.HTTP_200_OK)
4753

@@ -114,11 +120,27 @@ def connection_usage(request: Request, connection_id: str) -> Response:
114120
@handle_http_request
115121
@handle_permission
116122
def delete_connection(request: Request, connection_id: str) -> Response:
123+
from backend.errors.validation_exceptions import ConnectionDeleteFailed
124+
from backend.errors.visitran_backend_base_exceptions import VisitranBackendBaseException
125+
117126
con_context = ConnectionContext()
118-
con_context.delete_connection(connection_id=connection_id)
127+
conn_name = connection_id
128+
try:
129+
conn_data = con_context.get_connection(connection_id=connection_id)
130+
conn_name = conn_data.get("name", connection_id) if conn_data else connection_id
131+
con_context.delete_connection(connection_id=connection_id)
132+
except VisitranBackendBaseException:
133+
raise
134+
except Exception as e:
135+
fire_event(ConnectionDeleteFailedEvt(connection_name=conn_name, reason=str(e)))
136+
raise ConnectionDeleteFailed(
137+
connection_name=conn_name,
138+
reason=str(e),
139+
)
140+
fire_event(ConnectionDeletedEvt(connection_name=conn_name))
119141
response_data = {
120142
"status": "success",
121-
"data": f"{connection_id} is deleted successfully.",
143+
"data": f"{conn_name} is deleted successfully.",
122144
}
123145
return Response(data=response_data, status=status.HTTP_200_OK)
124146

@@ -158,4 +180,5 @@ def test_connection(request: Request) -> Response:
158180
)
159181
connection_id: str = cast(str, request_data.get("connection_id", "")) or None
160182
con_context.test_connection(datasource=datasource, connection_data=connection_data, connection_id=connection_id)
183+
fire_event(ConnectionTested(datasource=datasource, result="success"))
161184
return Response(data={"status": "success"}, status=status.HTTP_200_OK)

backend/backend/core/routers/environment/views.py

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from backend.core.utils import handle_http_request
1212
from backend.utils.constants import HTTPMethods
1313
from rbac.factory import handle_permission
14+
from visitran.events.functions import fire_event
15+
from visitran.events.types import EnvironmentCreated, EnvironmentDeleted
1416

1517
RESOURCE_NAME = "environmentmodels"
1618

@@ -50,6 +52,9 @@ def create_environment(request) -> Response:
5052
env_data: dict[str, Any] = env_context.create_environment(
5153
environment_details=request_payload
5254
)
55+
fire_event(EnvironmentCreated(
56+
environment_name=request_payload.get("name", ""),
57+
))
5358
response_data = {"status": "success", "data": env_data}
5459
return Response(data=response_data, status=status.HTTP_201_CREATED)
5560

@@ -71,34 +76,33 @@ def update_environment(request, environment_id: str) -> Response:
7176
@handle_http_request
7277
@handle_permission
7378
def delete_environment(request: Request, environment_id: str):
79+
from backend.core.models.environment_models import EnvironmentModels
80+
from backend.errors.validation_exceptions import EnvironmentInUse
81+
7482
env_context = EnvironmentContext()
83+
env_name = environment_id
84+
try:
85+
env_obj = EnvironmentModels.objects.get(environment_id=environment_id)
86+
env_name = env_obj.environment_name or environment_id
87+
except EnvironmentModels.DoesNotExist:
88+
pass
89+
7590
try:
7691
env_context.delete_environment(environment_id=environment_id)
77-
response_data = {"status": "success"}
78-
return Response(data=response_data, status=status.HTTP_200_OK)
7992
except ProtectedError as e:
80-
protected_objects = e.protected_objects
81-
blocked_apps = set()
82-
blocked_data = {}
83-
for obj in protected_objects:
84-
app_name = obj._meta.label.split(".")[
85-
0
86-
] # Extracts "appname. model_name can also be extracted like _meta.model_name"
87-
if app_name == "job_scheduler":
88-
key = "Deploy"
89-
if key not in blocked_data:
90-
blocked_data[key] = []
91-
blocked_data[key] = obj.task_name
92-
blocked_apps.add(app_name)
93-
error_details = []
94-
for model, ids in blocked_data.items():
95-
error_details.append(f"{ids} from '{model}'")
96-
error_message = f"Cannot delete this environment record because it is referenced by: {', '.join(error_details)}."
97-
data = {
98-
"message": error_message,
99-
"status": "failed",
100-
}
101-
return Response(data=data, status=status.HTTP_400_BAD_REQUEST)
93+
job_names = [
94+
obj.task_name
95+
for obj in e.protected_objects
96+
if obj._meta.label.split(".")[0] == "job_scheduler"
97+
]
98+
raise EnvironmentInUse(
99+
environment_name=env_name,
100+
job_names=", ".join(job_names) if job_names else "unknown",
101+
)
102+
103+
fire_event(EnvironmentDeleted(environment_name=env_name))
104+
response_data = {"status": "success"}
105+
return Response(data=response_data, status=status.HTTP_200_OK)
102106

103107

104108
@api_view([HTTPMethods.GET])

backend/backend/core/routers/explorer/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from backend.core.utils import handle_http_request
1111
from backend.utils.cache_service.decorators.cache_decorator import clear_cache
1212
from backend.utils.constants import HTTPMethods
13+
from visitran.events.functions import fire_event
14+
from visitran.events.types import ModelCreated, FileDeleted, FileRenamed
1315

1416

1517
@api_view([HTTPMethods.GET])
@@ -52,6 +54,7 @@ def create_model_explorer(request: Request, project_id: str) -> Response:
5254
model_name = request_data.get("model_name", "").replace(" ", "_").strip()
5355
app = ApplicationContext(project_id=project_id)
5456
app.create_a_model(model_name=model_name, is_generate_ai_request=False)
57+
fire_event(ModelCreated(model_name=model_name))
5558
return Response(data={"status": "success"}, status=status.HTTP_200_OK)
5659
except FileExistsError:
5760
return Response(
@@ -76,6 +79,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response:
7679
app = ApplicationContext(project_id=project_id)
7780
if wipe_all_enabled:
7881
app.cleanup_no_code_model(table_delete_enabled=table_delete_enabled)
82+
fire_event(FileDeleted(file_names="all models"))
7983
response_json = {"status": "success", "message": f"successfully deleted all model files"}
8084
else:
8185
# Build set of model names being deleted in this batch so that
@@ -95,6 +99,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response:
9599
)
96100
deleted_files.append(file_name)
97101

102+
fire_event(FileDeleted(file_names=", ".join(deleted_files)))
98103
response_json = {"status": "success", "message": f"successfully deleted files {deleted_files}"}
99104
return Response(data=response_json)
100105

@@ -109,6 +114,7 @@ def rename_a_file_or_folder(request: Request, project_id: str) -> Response:
109114
rename: str = request_data["rename"]
110115
app = ApplicationContext(project_id=project_id)
111116
refactored_models = app.rename_a_file_or_folder(file_path=file_name, rename=rename)
117+
fire_event(FileRenamed(old_name=file_name, new_name=rename))
112118
response_json = {"status": "success", "refactored_models": refactored_models}
113119
return Response(data=response_json)
114120

backend/backend/core/routers/transformation/views.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from backend.utils.cache_service.decorators.cache_decorator import clear_cache
1111
from backend.utils.constants import HTTPMethods
1212
from rbac.factory import handle_permission
13+
from visitran.events.functions import fire_event
14+
from visitran.events.types import TransformationApplied, TransformationDeleted, ModelConfigured
1315

1416
RESOURCE_NAME = "configmodels"
1517

@@ -98,6 +100,14 @@ def set_model_config_and_reference(
98100
request_data, model_name=file_name
99101
)
100102
response_json["status"] = "success"
103+
model_config = request_data.get("model_config", {})
104+
src = model_config.get("source", {})
105+
dest = model_config.get("model", {})
106+
fire_event(ModelConfigured(
107+
model_name=file_name,
108+
source=f"{src.get('schema_name', '')}.{src.get('table_name', '')}",
109+
destination=f"{dest.get('schema_name', '')}.{dest.get('table_name', '')}",
110+
))
101111
return Response(data=response_json)
102112

103113

@@ -116,6 +126,12 @@ def set_model_transformation(
116126
request_data, model_name=file_name
117127
)
118128
response_json["status"] = "success"
129+
step_config = request_data.get("step_config", {})
130+
transformation_type = step_config.get("type", "unknown") if isinstance(step_config, dict) else "unknown"
131+
fire_event(TransformationApplied(
132+
model_name=file_name,
133+
transformation_type=transformation_type,
134+
))
119135
return Response(data=response_json)
120136

121137

@@ -138,6 +154,10 @@ def delete_model_transformation(
138154
is_clear_all=is_clear_all,
139155
)
140156
response_json["status"] = "success"
157+
fire_event(TransformationDeleted(
158+
model_name=file_name,
159+
transformation_type="all" if is_clear_all else (transformation_id or "unknown"),
160+
))
141161
return Response(data=response_json)
142162

143163

backend/backend/core/scheduler/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from backend.core.scheduler.serializer import TaskRunHistorySerializer
1717
from backend.core.scheduler.task_constant import Task, TaskStatus, TaskType
1818
from backend.utils.tenant_context import get_organization
19+
from visitran.events.functions import fire_event
20+
from visitran.events.types import JobCreated, JobUpdated, JobDeleted, JobTriggered
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -343,6 +345,10 @@ def create_periodic_task(request, project_id):
343345
)
344346
periodic_task.save()
345347

348+
fire_event(JobCreated(
349+
job_name=task_name,
350+
environment_name=getattr(environment, "environment_name", ""),
351+
))
346352
return Response(
347353
{"status": "Task created successfully"},
348354
status=status.HTTP_201_CREATED,
@@ -530,6 +536,7 @@ def update_periodic_task(request, project_id, user_task_id):
530536
)
531537
periodic_task.save(update_fields=["kwargs"])
532538

539+
fire_event(JobUpdated(job_name=user_task.task_name))
533540
return Response(
534541
{"status": "Task updated successfully"},
535542
status=status.HTTP_200_OK,
@@ -554,11 +561,13 @@ def delete_periodic_task(request, project_id, task_id):
554561
user_task = UserTaskDetails.objects.select_related(
555562
"periodic_task"
556563
).get(periodic_task_id=task_id, project__project_uuid=project_id)
564+
task_name = user_task.task_name
557565
periodic_task = user_task.periodic_task
558566
user_task.delete()
559567
if periodic_task:
560568
periodic_task.delete()
561569

570+
fire_event(JobDeleted(job_name=task_name))
562571
return Response(
563572
{"status": "Task deleted successfully"},
564573
status=status.HTTP_200_OK,
@@ -630,6 +639,7 @@ def _dispatch_task_run(task, user_id, models_override=None):
630639
scheduler path hits ``trigger_scheduled_run`` without this dispatch
631640
wrapper, and it keeps the default ``trigger="scheduled"``.
632641
"""
642+
scope = models_override[0] if models_override and len(models_override) == 1 else "job"
633643
run_kwargs = {
634644
"user_task_id": task.id,
635645
"user_id": user_id,
@@ -649,6 +659,7 @@ def _dispatch_task_run(task, user_id, models_override=None):
649659
task.task_run_time = timezone.now()
650660
task.save(update_fields=["status", "task_run_time"])
651661

662+
fire_event(JobTriggered(job_name=task.task_name, scope=scope))
652663
return Response(
653664
{"success": True, "data": "Job submitted to Celery broker."},
654665
status=status.HTTP_200_OK,
@@ -661,6 +672,7 @@ def _dispatch_task_run(task, user_id, models_override=None):
661672

662673
trigger_scheduled_run(**run_kwargs)
663674

675+
fire_event(JobTriggered(job_name=task.task_name, scope=scope))
664676
return Response(
665677
{"success": True, "data": "Job executed synchronously (no broker)."},
666678
status=status.HTTP_200_OK,

backend/backend/errors/error_codes.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,19 @@ class BackendErrorMessages(BaseConstant):
8787
"\nPlease delete the projects or **ask for a feature to modify the connections in projects** and retry."
8888
)
8989

90+
CONNECTION_DELETE_FAILED = (
91+
'### **Connection Delete Failed!**\n'
92+
'Unable to delete connection **"{connection_name}"**.\n\n'
93+
'Reason: {reason}'
94+
)
95+
96+
ENVIRONMENT_IN_USE = (
97+
'### **Environment In Use!**\n'
98+
'Environment **"{environment_name}"** cannot be deleted because it is '
99+
'referenced by the following job(s): **{job_names}**.\n\n'
100+
'Please remove the environment from these jobs first, then delete.'
101+
)
102+
90103
MODEL_ALREADY_EXISTS = (
91104
'**Model Exists!**\nModel "{model_name}" already created at {created_at}. '
92105
"Choose a unique name or delete the existing one."

backend/backend/errors/validation_exceptions.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,39 @@ def __init__(self, prohibited_action: str, prohibited_actions: list[str]) -> Non
138138
prohibited_action=prohibited_action,
139139
prohibited_actions=prohibited_actions,
140140
)
141+
142+
143+
class EnvironmentInUse(VisitranBackendBaseException):
144+
"""
145+
Raised when trying to delete an environment that is referenced by scheduled jobs.
146+
"""
147+
148+
def __init__(self, environment_name: str, job_names: str) -> None:
149+
super().__init__(
150+
error_code=BackendErrorMessages.ENVIRONMENT_IN_USE,
151+
http_status_code=status.HTTP_400_BAD_REQUEST,
152+
environment_name=environment_name,
153+
job_names=job_names,
154+
)
155+
156+
@property
157+
def severity(self) -> str:
158+
return "Warning"
159+
160+
161+
class ConnectionDeleteFailed(VisitranBackendBaseException):
162+
"""
163+
Raised when a connection cannot be deleted.
164+
"""
165+
166+
def __init__(self, connection_name: str, reason: str) -> None:
167+
super().__init__(
168+
error_code=BackendErrorMessages.CONNECTION_DELETE_FAILED,
169+
http_status_code=status.HTTP_400_BAD_REQUEST,
170+
connection_name=connection_name,
171+
reason=reason,
172+
)
173+
174+
@property
175+
def severity(self) -> str:
176+
return "Warning"

backend/visitran/events/base_types.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class BaseEvent:
2828
def level_tag(self) -> EventLevel:
2929
return EventLevel.DEBUG
3030

31+
def audience(self) -> str:
32+
return "developer"
33+
3134
def message(self) -> str:
3235
raise NotImplementedError("message() not implemented for event")
3336

@@ -87,6 +90,29 @@ def level_tag(self) -> EventLevel:
8790
return EventLevel.ERROR
8891

8992

93+
@dataclass
94+
class UserLevel(BaseEvent):
95+
"""User-facing events shown in the activity log (not developer noise)."""
96+
97+
def level_tag(self) -> EventLevel:
98+
return EventLevel.INFO
99+
100+
def audience(self) -> str:
101+
return "user"
102+
103+
def title(self) -> str:
104+
"""Clean, short title for the activity feed."""
105+
return self.message()
106+
107+
def subtitle(self) -> str:
108+
"""Contextual metadata shown below the title."""
109+
return ""
110+
111+
def event_status(self) -> str:
112+
"""One of: running, success, error, warning, info."""
113+
return "success"
114+
115+
90116
class NoFile:
91117
"""Prevents an event from going to the file."""
92118

0 commit comments

Comments
 (0)