Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fd3161c
feat: user-facing activity logs — 3 core model events
abhizipstack Apr 16, 2026
3ade023
fix: CreateConnection crash — hasDetailsChanged used before declaration
abhizipstack Apr 16, 2026
18663e5
feat: style user-activity logs with visual distinction
abhizipstack Apr 16, 2026
4bc2ce7
fix: rename event classes to match proto Msg naming convention
abhizipstack Apr 16, 2026
a978408
feat: add P1 user-facing events — transformations, config, seeds
abhizipstack Apr 16, 2026
6c7d17d
feat: add P2 user-facing events — job scheduler operations
abhizipstack Apr 16, 2026
a4293ba
feat: add P3-P5 user-facing events — CRUD, connections, environments
abhizipstack Apr 16, 2026
2c845c2
fix: show names instead of IDs in connection/environment delete events
abhizipstack Apr 16, 2026
f9c45eb
fix: environment delete shows proper error when used by a job
abhizipstack Apr 16, 2026
2216621
fix: address Greptile review — name lookup safety, seed schema, field…
abhizipstack Apr 16, 2026
e382abe
fix: restructure delete_connection — fetch + delete in same try block
abhizipstack Apr 16, 2026
66f1d53
fix: raise ConnectionDeleteFailed exception with proper formatting
abhizipstack Apr 16, 2026
4b708f8
fix: separate success and failure events for connection delete
abhizipstack Apr 16, 2026
d0bbd39
fix: move success fire_event outside try to prevent false failure
abhizipstack Apr 17, 2026
ab08e61
fix: fire JobTriggered after successful dispatch, not before
abhizipstack Apr 17, 2026
8553e8d
fix: improve activity log readability and celery log queue
abhizipstack Apr 17, 2026
5193808
feat: structured activity feed for user-facing logs
abhizipstack Apr 17, 2026
03766bb
fix: preserve specific exception status codes in connection delete
abhizipstack Apr 17, 2026
96ad05c
fix: reset pagination to page 1 on job switch in run history
abhizipstack Apr 17, 2026
fe95b48
fix: rename status() to event_status() to avoid proto field shadowing
abhizipstack Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions backend/backend/core/routers/connection/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from backend.core.utils import handle_http_request
from backend.utils.constants import HTTPMethods
from rbac.factory import handle_permission
from visitran.events.functions import fire_event
from visitran.events.types import ConnectionCreated, ConnectionTested, ConnectionDeletedEvt, ConnectionDeleteFailedEvt

RESOURCE_NAME = "connectiondetails"

Expand Down Expand Up @@ -42,6 +44,10 @@ def create_connection(request: Request) -> Response:
connection_data = con_context.create_connection(
connection_details=request_payload, force_create=bool(force_create)
)
fire_event(ConnectionCreated(
connection_name=request_payload.get("name", ""),
datasource=request_payload.get("datasource_name", ""),
))
response_data = {"status": "success", "data": connection_data}
return Response(data=response_data, status=status.HTTP_200_OK)

Expand Down Expand Up @@ -114,11 +120,27 @@ def connection_usage(request: Request, connection_id: str) -> Response:
@handle_http_request
@handle_permission
def delete_connection(request: Request, connection_id: str) -> Response:
from backend.errors.validation_exceptions import ConnectionDeleteFailed
from backend.errors.visitran_backend_base_exceptions import VisitranBackendBaseException

con_context = ConnectionContext()
con_context.delete_connection(connection_id=connection_id)
conn_name = connection_id
try:
conn_data = con_context.get_connection(connection_id=connection_id)
conn_name = conn_data.get("name", connection_id) if conn_data else connection_id
con_context.delete_connection(connection_id=connection_id)
except VisitranBackendBaseException:
raise
except Exception as e:
fire_event(ConnectionDeleteFailedEvt(connection_name=conn_name, reason=str(e)))
raise ConnectionDeleteFailed(
connection_name=conn_name,
reason=str(e),
)
Comment thread
abhizipstack marked this conversation as resolved.
fire_event(ConnectionDeletedEvt(connection_name=conn_name))
response_data = {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
"status": "success",
"data": f"{connection_id} is deleted successfully.",
"data": f"{conn_name} is deleted successfully.",
}
return Response(data=response_data, status=status.HTTP_200_OK)

Comment thread
abhizipstack marked this conversation as resolved.
Expand Down Expand Up @@ -158,4 +180,5 @@ def test_connection(request: Request) -> Response:
)
connection_id: str = cast(str, request_data.get("connection_id", "")) or None
con_context.test_connection(datasource=datasource, connection_data=connection_data, connection_id=connection_id)
fire_event(ConnectionTested(datasource=datasource, result="success"))
return Response(data={"status": "success"}, status=status.HTTP_200_OK)
52 changes: 28 additions & 24 deletions backend/backend/core/routers/environment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from backend.core.utils import handle_http_request
from backend.utils.constants import HTTPMethods
from rbac.factory import handle_permission
from visitran.events.functions import fire_event
from visitran.events.types import EnvironmentCreated, EnvironmentDeleted

RESOURCE_NAME = "environmentmodels"

Expand Down Expand Up @@ -50,6 +52,9 @@ def create_environment(request) -> Response:
env_data: dict[str, Any] = env_context.create_environment(
environment_details=request_payload
)
fire_event(EnvironmentCreated(
environment_name=request_payload.get("name", ""),
))
response_data = {"status": "success", "data": env_data}
return Response(data=response_data, status=status.HTTP_201_CREATED)

Expand All @@ -71,34 +76,33 @@ def update_environment(request, environment_id: str) -> Response:
@handle_http_request
@handle_permission
def delete_environment(request: Request, environment_id: str):
from backend.core.models.environment_models import EnvironmentModels
from backend.errors.validation_exceptions import EnvironmentInUse

env_context = EnvironmentContext()
env_name = environment_id
try:
env_obj = EnvironmentModels.objects.get(environment_id=environment_id)
env_name = env_obj.environment_name or environment_id
except EnvironmentModels.DoesNotExist:
pass

try:
env_context.delete_environment(environment_id=environment_id)
response_data = {"status": "success"}
return Response(data=response_data, status=status.HTTP_200_OK)
except ProtectedError as e:
protected_objects = e.protected_objects
blocked_apps = set()
blocked_data = {}
for obj in protected_objects:
app_name = obj._meta.label.split(".")[
0
] # Extracts "appname. model_name can also be extracted like _meta.model_name"
if app_name == "job_scheduler":
key = "Deploy"
if key not in blocked_data:
blocked_data[key] = []
blocked_data[key] = obj.task_name
blocked_apps.add(app_name)
error_details = []
for model, ids in blocked_data.items():
error_details.append(f"{ids} from '{model}'")
error_message = f"Cannot delete this environment record because it is referenced by: {', '.join(error_details)}."
data = {
"message": error_message,
"status": "failed",
}
return Response(data=data, status=status.HTTP_400_BAD_REQUEST)
job_names = [
obj.task_name
for obj in e.protected_objects
if obj._meta.label.split(".")[0] == "job_scheduler"
]
raise EnvironmentInUse(
environment_name=env_name,
job_names=", ".join(job_names) if job_names else "unknown",
)

fire_event(EnvironmentDeleted(environment_name=env_name))
response_data = {"status": "success"}
return Response(data=response_data, status=status.HTTP_200_OK)


@api_view([HTTPMethods.GET])
Expand Down
6 changes: 6 additions & 0 deletions backend/backend/core/routers/explorer/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from backend.core.utils import handle_http_request
from backend.utils.cache_service.decorators.cache_decorator import clear_cache
from backend.utils.constants import HTTPMethods
from visitran.events.functions import fire_event
from visitran.events.types import ModelCreated, FileDeleted, FileRenamed


@api_view([HTTPMethods.GET])
Expand Down Expand Up @@ -52,6 +54,7 @@ def create_model_explorer(request: Request, project_id: str) -> Response:
model_name = request_data.get("model_name", "").replace(" ", "_").strip()
app = ApplicationContext(project_id=project_id)
app.create_a_model(model_name=model_name, is_generate_ai_request=False)
fire_event(ModelCreated(model_name=model_name))
return Response(data={"status": "success"}, status=status.HTTP_200_OK)
except FileExistsError:
return Response(
Expand All @@ -76,6 +79,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response:
app = ApplicationContext(project_id=project_id)
if wipe_all_enabled:
app.cleanup_no_code_model(table_delete_enabled=table_delete_enabled)
fire_event(FileDeleted(file_names="all models"))
response_json = {"status": "success", "message": f"successfully deleted all model files"}
else:
# Build set of model names being deleted in this batch so that
Expand All @@ -95,6 +99,7 @@ def delete_a_file_or_folder(request: Request, project_id: str) -> Response:
)
deleted_files.append(file_name)

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

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

Expand Down
20 changes: 20 additions & 0 deletions backend/backend/core/routers/transformation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from backend.utils.cache_service.decorators.cache_decorator import clear_cache
from backend.utils.constants import HTTPMethods
from rbac.factory import handle_permission
from visitran.events.functions import fire_event
from visitran.events.types import TransformationApplied, TransformationDeleted, ModelConfigured

RESOURCE_NAME = "configmodels"

Expand Down Expand Up @@ -98,6 +100,14 @@ def set_model_config_and_reference(
request_data, model_name=file_name
)
response_json["status"] = "success"
model_config = request_data.get("model_config", {})
src = model_config.get("source", {})
dest = model_config.get("model", {})
fire_event(ModelConfigured(
model_name=file_name,
source=f"{src.get('schema_name', '')}.{src.get('table_name', '')}",
destination=f"{dest.get('schema_name', '')}.{dest.get('table_name', '')}",
))
return Response(data=response_json)


Expand All @@ -116,6 +126,12 @@ def set_model_transformation(
request_data, model_name=file_name
)
response_json["status"] = "success"
step_config = request_data.get("step_config", {})
transformation_type = step_config.get("type", "unknown") if isinstance(step_config, dict) else "unknown"
fire_event(TransformationApplied(
model_name=file_name,
transformation_type=transformation_type,
))
return Response(data=response_json)


Expand All @@ -138,6 +154,10 @@ def delete_model_transformation(
is_clear_all=is_clear_all,
)
response_json["status"] = "success"
fire_event(TransformationDeleted(
model_name=file_name,
transformation_type="all" if is_clear_all else (transformation_id or "unknown"),
))
return Response(data=response_json)


Expand Down
12 changes: 12 additions & 0 deletions backend/backend/core/scheduler/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from backend.core.scheduler.serializer import TaskRunHistorySerializer
from backend.core.scheduler.task_constant import Task, TaskStatus, TaskType
from backend.utils.tenant_context import get_organization
from visitran.events.functions import fire_event
from visitran.events.types import JobCreated, JobUpdated, JobDeleted, JobTriggered

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -343,6 +345,10 @@ def create_periodic_task(request, project_id):
)
periodic_task.save()

fire_event(JobCreated(
job_name=task_name,
environment_name=getattr(environment, "environment_name", ""),
))
return Response(
{"status": "Task created successfully"},
status=status.HTTP_201_CREATED,
Expand Down Expand Up @@ -530,6 +536,7 @@ def update_periodic_task(request, project_id, user_task_id):
)
periodic_task.save(update_fields=["kwargs"])

fire_event(JobUpdated(job_name=user_task.task_name))
return Response(
{"status": "Task updated successfully"},
status=status.HTTP_200_OK,
Expand All @@ -554,11 +561,13 @@ def delete_periodic_task(request, project_id, task_id):
user_task = UserTaskDetails.objects.select_related(
"periodic_task"
).get(periodic_task_id=task_id, project__project_uuid=project_id)
task_name = user_task.task_name
periodic_task = user_task.periodic_task
user_task.delete()
if periodic_task:
periodic_task.delete()

fire_event(JobDeleted(job_name=task_name))
return Response(
{"status": "Task deleted successfully"},
status=status.HTTP_200_OK,
Expand Down Expand Up @@ -630,6 +639,7 @@ def _dispatch_task_run(task, user_id, models_override=None):
scheduler path hits ``trigger_scheduled_run`` without this dispatch
wrapper, and it keeps the default ``trigger="scheduled"``.
"""
scope = models_override[0] if models_override and len(models_override) == 1 else "job"
run_kwargs = {
"user_task_id": task.id,
"user_id": user_id,
Expand All @@ -649,6 +659,7 @@ def _dispatch_task_run(task, user_id, models_override=None):
task.task_run_time = timezone.now()
task.save(update_fields=["status", "task_run_time"])

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

trigger_scheduled_run(**run_kwargs)

fire_event(JobTriggered(job_name=task.task_name, scope=scope))
return Response(
{"success": True, "data": "Job executed synchronously (no broker)."},
status=status.HTTP_200_OK,
Expand Down
13 changes: 13 additions & 0 deletions backend/backend/errors/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ class BackendErrorMessages(BaseConstant):
"\nPlease delete the projects or **ask for a feature to modify the connections in projects** and retry."
)

CONNECTION_DELETE_FAILED = (
'### **Connection Delete Failed!**\n'
'Unable to delete connection **"{connection_name}"**.\n\n'
'Reason: {reason}'
)

ENVIRONMENT_IN_USE = (
'### **Environment In Use!**\n'
'Environment **"{environment_name}"** cannot be deleted because it is '
'referenced by the following job(s): **{job_names}**.\n\n'
'Please remove the environment from these jobs first, then delete.'
)

MODEL_ALREADY_EXISTS = (
'**Model Exists!**\nModel "{model_name}" already created at {created_at}. '
"Choose a unique name or delete the existing one."
Expand Down
36 changes: 36 additions & 0 deletions backend/backend/errors/validation_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,39 @@ def __init__(self, prohibited_action: str, prohibited_actions: list[str]) -> Non
prohibited_action=prohibited_action,
prohibited_actions=prohibited_actions,
)


class EnvironmentInUse(VisitranBackendBaseException):
"""
Raised when trying to delete an environment that is referenced by scheduled jobs.
"""

def __init__(self, environment_name: str, job_names: str) -> None:
super().__init__(
error_code=BackendErrorMessages.ENVIRONMENT_IN_USE,
http_status_code=status.HTTP_400_BAD_REQUEST,
environment_name=environment_name,
job_names=job_names,
)

@property
def severity(self) -> str:
return "Warning"


class ConnectionDeleteFailed(VisitranBackendBaseException):
"""
Raised when a connection cannot be deleted.
"""

def __init__(self, connection_name: str, reason: str) -> None:
super().__init__(
error_code=BackendErrorMessages.CONNECTION_DELETE_FAILED,
http_status_code=status.HTTP_400_BAD_REQUEST,
connection_name=connection_name,
reason=reason,
)

@property
def severity(self) -> str:
return "Warning"
26 changes: 26 additions & 0 deletions backend/visitran/events/base_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class BaseEvent:
def level_tag(self) -> EventLevel:
return EventLevel.DEBUG

def audience(self) -> str:
return "developer"

def message(self) -> str:
raise NotImplementedError("message() not implemented for event")

Expand Down Expand Up @@ -87,6 +90,29 @@ def level_tag(self) -> EventLevel:
return EventLevel.ERROR


@dataclass
class UserLevel(BaseEvent):
"""User-facing events shown in the activity log (not developer noise)."""

def level_tag(self) -> EventLevel:
return EventLevel.INFO

def audience(self) -> str:
return "user"

def title(self) -> str:
"""Clean, short title for the activity feed."""
return self.message()

def subtitle(self) -> str:
"""Contextual metadata shown below the title."""
return ""

def event_status(self) -> str:
"""One of: running, success, error, warning, info."""
return "success"


class NoFile:
"""Prevents an event from going to the file."""

Expand Down
Loading
Loading