mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
227 lines
8.9 KiB
Python
227 lines
8.9 KiB
Python
from django.contrib import admin, messages
|
|
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
|
from django.shortcuts import redirect
|
|
from django.urls import path, reverse
|
|
from django.utils.html import format_html
|
|
|
|
from insights.storage import object_storage
|
|
|
|
from . import loop_service
|
|
from .models import CodeInvite, CodeInviteRedemption, Loop, LoopTrigger, SandboxSnapshot, Task, TaskRun
|
|
|
|
|
|
@admin.register(Task)
|
|
class TaskAdmin(admin.ModelAdmin):
|
|
list_display = ("slug", "title", "origin_product", "internal", "team", "created_by", "created_at", "deleted")
|
|
list_filter = ("origin_product", "internal", "deleted", "created_at")
|
|
search_fields = ("title", "description", "repository")
|
|
readonly_fields = ("id", "slug", "task_number", "created_at", "updated_at", "deleted_at")
|
|
autocomplete_fields = ("team", "created_by", "github_integration", "github_user_integration")
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("id", "slug", "task_number", "title", "description", "origin_product", "internal")}),
|
|
("Team & User", {"fields": ("team", "created_by")}),
|
|
("Repository", {"fields": ("github_integration", "github_user_integration", "repository")}),
|
|
("Schema", {"fields": ("json_schema",)}),
|
|
("Status", {"fields": ("deleted", "deleted_at")}),
|
|
("Dates", {"fields": ("created_at", "updated_at")}),
|
|
)
|
|
|
|
|
|
@admin.register(TaskRun)
|
|
class TaskRunAdmin(admin.ModelAdmin):
|
|
list_display = ("id", "task", "status", "environment", "stage", "created_at")
|
|
list_filter = ("status", "environment", "created_at")
|
|
search_fields = ("task__title", "branch", "stage")
|
|
readonly_fields = ("id", "created_at", "updated_at", "completed_at", "download_logs_link")
|
|
autocomplete_fields = ("task",)
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("id", "task", "status", "environment", "stage", "branch")}),
|
|
("Storage", {"fields": ("error_message", "download_logs_link")}),
|
|
("Data", {"fields": ("output", "state")}),
|
|
("Dates", {"fields": ("created_at", "updated_at", "completed_at")}),
|
|
)
|
|
|
|
def get_urls(self) -> list:
|
|
# Prepended so it isn't shadowed by the default `<path:object_id>/` route.
|
|
return [
|
|
path(
|
|
"<uuid:run_id>/download-logs/",
|
|
self.admin_site.admin_view(self.download_logs),
|
|
name="tasks_taskrun_download_logs",
|
|
),
|
|
*super().get_urls(),
|
|
]
|
|
|
|
def download_logs(self, request: HttpRequest, run_id) -> HttpResponse:
|
|
run = self.get_object(request, run_id)
|
|
if run is None:
|
|
raise Http404("Task run not found")
|
|
if object_storage.head_object(run.log_url) is None:
|
|
self.message_user(
|
|
request,
|
|
"No logs available for this run — they may not have been written yet, or object storage is unreachable.",
|
|
level=messages.WARNING,
|
|
)
|
|
return redirect(reverse("admin:tasks_taskrun_change", args=[run_id]))
|
|
filename = f"run_{run.id}.jsonl"
|
|
url = object_storage.get_presigned_url(
|
|
run.log_url,
|
|
expiration=300,
|
|
content_disposition=f'attachment; filename="{filename}"',
|
|
)
|
|
if not url:
|
|
self.message_user(
|
|
request,
|
|
"Could not generate a download link for this run's logs (object storage unavailable).",
|
|
level=messages.WARNING,
|
|
)
|
|
return redirect(reverse("admin:tasks_taskrun_change", args=[run_id]))
|
|
return HttpResponseRedirect(url)
|
|
|
|
@admin.display(description="Logs")
|
|
def download_logs_link(self, obj: TaskRun) -> str:
|
|
if not obj or not obj.pk:
|
|
return "—"
|
|
url = reverse("admin:tasks_taskrun_download_logs", args=[obj.pk])
|
|
return format_html('<a class="button" href="{}">Download logs</a>', url)
|
|
|
|
|
|
@admin.register(SandboxSnapshot)
|
|
class SandboxSnapshotAdmin(admin.ModelAdmin):
|
|
list_display = ("external_id", "status", "created_at", "updated_at")
|
|
list_filter = ("status", "created_at")
|
|
search_fields = ("external_id", "repos")
|
|
readonly_fields = ("id", "external_id", "created_at", "updated_at")
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("id", "external_id", "status")}),
|
|
("Repository Info", {"fields": ("repos",)}),
|
|
("Metadata", {"fields": ("metadata",)}),
|
|
("Dates", {"fields": ("created_at", "updated_at")}),
|
|
)
|
|
|
|
|
|
class CodeInviteRedemptionInline(admin.TabularInline):
|
|
model = CodeInviteRedemption
|
|
extra = 0
|
|
can_delete = False
|
|
readonly_fields = ("id", "user", "organization", "redeemed_at")
|
|
|
|
|
|
@admin.register(CodeInvite)
|
|
class CodeInviteAdmin(admin.ModelAdmin):
|
|
list_display = (
|
|
"code",
|
|
"description",
|
|
"is_active",
|
|
"redemption_count",
|
|
"max_redemptions",
|
|
"expires_at",
|
|
"created_at",
|
|
)
|
|
list_filter = ("is_active", "created_at")
|
|
search_fields = ("code", "description")
|
|
readonly_fields = ("id", "redemption_count", "created_at")
|
|
autocomplete_fields = ("created_by",)
|
|
inlines = []
|
|
|
|
def get_fieldsets(self, request, obj=None):
|
|
if obj:
|
|
return (
|
|
(None, {"fields": ("id", "code", "description")}),
|
|
("Limits", {"fields": ("is_active", "max_redemptions", "redemption_count", "expires_at")}),
|
|
("Metadata", {"fields": ("created_by", "created_at")}),
|
|
)
|
|
# On add, code may be set manually or left blank to auto-generate on save
|
|
return (
|
|
(None, {"fields": ("id", "code", "description")}),
|
|
("Limits", {"fields": ("is_active", "max_redemptions", "expires_at")}),
|
|
("Metadata", {"fields": ("created_by",)}),
|
|
)
|
|
|
|
def get_inlines(self, request, obj=None):
|
|
return [CodeInviteRedemptionInline]
|
|
|
|
|
|
@admin.register(CodeInviteRedemption)
|
|
class CodeInviteRedemptionAdmin(admin.ModelAdmin):
|
|
list_display = ("invite_code", "user", "organization", "redeemed_at")
|
|
list_filter = ("redeemed_at",)
|
|
search_fields = ("user__email", "invite_code__code")
|
|
readonly_fields = ("id", "invite_code", "user", "organization", "redeemed_at")
|
|
|
|
|
|
@admin.register(Loop)
|
|
class LoopAdmin(admin.ModelAdmin):
|
|
list_display = (
|
|
"name",
|
|
"visibility",
|
|
"enabled",
|
|
"team",
|
|
"created_by",
|
|
"last_run_at",
|
|
"last_run_status",
|
|
"consecutive_failures",
|
|
"deleted",
|
|
)
|
|
list_filter = ("visibility", "enabled", "deleted", "overlap_policy", "runtime_adapter")
|
|
search_fields = ("name", "description")
|
|
readonly_fields = (
|
|
"id",
|
|
"enabled",
|
|
"last_run_at",
|
|
"last_run_status",
|
|
"last_error",
|
|
"consecutive_failures",
|
|
"created_at",
|
|
"updated_at",
|
|
)
|
|
autocomplete_fields = ("team", "created_by", "creator")
|
|
raw_id_fields = ("sandbox_environment",)
|
|
|
|
def get_queryset(self, request: HttpRequest):
|
|
# Admin has no team context; Loop's default manager is fail-closed.
|
|
return Loop.objects.unscoped().select_related("team", "created_by")
|
|
|
|
def delete_model(self, request: HttpRequest, obj: Loop) -> None:
|
|
# Tear down Temporal Schedules before the row is gone; CASCADE never talks to Temporal, so
|
|
# a raw admin delete would otherwise leave the schedules firing forever.
|
|
loop_service.delete_loop_schedules(obj)
|
|
super().delete_model(request, obj)
|
|
|
|
def delete_queryset(self, request: HttpRequest, queryset) -> None:
|
|
for loop in queryset:
|
|
loop_service.delete_loop_schedules(loop)
|
|
super().delete_queryset(request, queryset)
|
|
|
|
|
|
@admin.register(LoopTrigger)
|
|
class LoopTriggerAdmin(admin.ModelAdmin):
|
|
list_display = ("id", "loop", "type", "enabled", "schedule_sync_status", "last_fired_at")
|
|
list_filter = ("type", "enabled", "schedule_sync_status")
|
|
search_fields = ("loop__name",)
|
|
readonly_fields = ("id", "loop", "enabled", "schedule_sync_status", "last_fired_at", "created_at", "updated_at")
|
|
autocomplete_fields = ("team",)
|
|
|
|
def get_queryset(self, request: HttpRequest):
|
|
# Admin has no team context; select_related("loop") also keeps readonly
|
|
# rendering off Loop's fail-closed base manager.
|
|
return LoopTrigger.objects.unscoped().select_related("loop", "team")
|
|
|
|
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
# Triggers are created through the loops API; their Temporal Schedule
|
|
# identity hangs off the row id, so hand-created rows would drift.
|
|
return False
|
|
|
|
def delete_model(self, request: HttpRequest, obj: LoopTrigger) -> None:
|
|
# Tear down the Temporal Schedule before the row is gone (CASCADE won't).
|
|
loop_service.delete_loop_trigger_schedule(obj)
|
|
super().delete_model(request, obj)
|
|
|
|
def delete_queryset(self, request: HttpRequest, queryset) -> None:
|
|
for trigger in queryset:
|
|
loop_service.delete_loop_trigger_schedule(trigger)
|
|
super().delete_queryset(request, queryset)
|