| """ | |
| Complete views for the model versioning app. | |
| Covers version listing, detail, diffing, reconstruction, compression catalog, | |
| lineage graph, lifecycle migration, download, publish, archive/restore, cleanup, | |
| and production flagging. | |
| """ | |
| import csv | |
| import io | |
| import logging | |
| import os | |
| import tarfile | |
| import tempfile | |
| try: | |
| import boto3 | |
| except ImportError: | |
| boto3 = None | |
| from django.conf import settings | |
| from django.contrib.auth.models import User # add this | |
| from django.db.models import Q | |
| from django.http import FileResponse, Http404, HttpResponse | |
| from django.shortcuts import get_object_or_404 | |
| from jobscheduler.models import Job | |
| from rest_framework.decorators import api_view, permission_classes | |
| from rest_framework.pagination import PageNumberPagination | |
| from rest_framework.permissions import IsAuthenticated | |
| from rest_framework.response import Response | |
| from .models import ModelCommit # add these | |
| from .models import ( | |
| ModelAccessControl, | |
| ModelApproval, | |
| ModelBranch, | |
| ModelSecurityScan, | |
| ModelUsageLog, | |
| ModelVersion, | |
| ModelVersionDiff, | |
| ) | |
| from .serializers import ModelUsageLogSerializer # add these | |
| from .serializers import ( | |
| ModelAccessControlSerializer, | |
| ModelApprovalSerializer, | |
| ModelSecurityScanSerializer, | |
| ModelVersionSerializer, | |
| ) | |
| from .services import create_diff, load_version_from_diff, migrate_storage_tier | |
| from .utils import HuggingFacePublisher, cleanup_old_versions | |
| logger = logging.getLogger(__name__) | |
| class StandardPagination(PageNumberPagination): | |
| page_size = 20 | |
| page_size_query_param = "page_size" | |
| max_page_size = 100 | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Global version listing (recent 50) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def all_model_versions(request): | |
| """Return the most recent 50 model versions across all runs.""" | |
| versions = ModelVersion.objects.select_related("run").order_by("-created_at")[:50] | |
| data = [ | |
| { | |
| "id": str(v.id), | |
| "run_id": str(v.run_id), | |
| "run_name": v.run.name, | |
| "version": v.version, | |
| "version_number": v.version_number, | |
| "metrics": v.metrics, | |
| "artifact_size_mb": v.artifact_size_mb, | |
| "compressed_size_mb": v.compressed_size_mb, | |
| "is_published_to_hub": v.is_published_to_hub, | |
| "is_production": v.is_production, | |
| "storage_tier": v.storage_tier, | |
| "created_at": v.created_at.isoformat(), | |
| } | |
| for v in versions | |
| ] | |
| return Response(data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Per‑run version listing (paginated, filterable, CSV export) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def list_model_versions(request, run_id): | |
| """List versions for a specific run with optional filtering, sorting, and CSV export.""" | |
| run = get_object_or_404(Job, id=run_id) | |
| queryset = run.versioning_model_versions.all().order_by("-version_number") | |
| # Filtering | |
| validation = request.query_params.get("validation_passed") | |
| if validation is not None: | |
| queryset = queryset.filter(validation_passed=validation.lower() == "true") | |
| published = request.query_params.get("published") | |
| if published is not None: | |
| queryset = queryset.filter(is_published_to_hub=published.lower() == "true") | |
| storage_tier = request.query_params.get("storage_tier") | |
| if storage_tier in ["hot", "warm", "cold"]: | |
| queryset = queryset.filter(storage_tier=storage_tier) | |
| compression_method = request.query_params.get("compression_method") | |
| if compression_method: | |
| queryset = queryset.filter(compression_method=compression_method) | |
| search = request.query_params.get("search") | |
| if search: | |
| queryset = queryset.filter(Q(version__icontains=search)) | |
| ordering = request.query_params.get("ordering", "-version_number") | |
| allowed_orders = [ | |
| "version_number", | |
| "created_at", | |
| "artifact_size_mb", | |
| "compressed_size_mb", | |
| "-version_number", | |
| "-created_at", | |
| "-artifact_size_mb", | |
| "-compressed_size_mb", | |
| ] | |
| if ordering in allowed_orders: | |
| queryset = queryset.order_by(ordering) | |
| else: | |
| queryset = queryset.order_by("-version_number") | |
| # CSV export | |
| if request.query_params.get("export_csv") == "true": | |
| response = HttpResponse(content_type="text/csv") | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="model_versions_run_{run.id}.csv"' | |
| ) | |
| writer = csv.writer(response) | |
| writer.writerow( | |
| [ | |
| "Version Number", | |
| "Version Name", | |
| "Size (MB)", | |
| "Compressed Size (MB)", | |
| "Compression Method", | |
| "Validation Passed", | |
| "Published", | |
| "Storage Tier", | |
| "Accuracy", | |
| "Loss", | |
| "Created At", | |
| ] | |
| ) | |
| for v in queryset: | |
| writer.writerow( | |
| [ | |
| v.version_number, | |
| v.version, | |
| v.artifact_size_mb, | |
| v.compressed_size_mb, | |
| v.compression_method or "none", | |
| v.validation_passed, | |
| v.is_published_to_hub, | |
| v.storage_tier, | |
| v.metrics.get("eval_accuracy", "N/A"), | |
| v.metrics.get("eval_loss", "N/A"), | |
| v.created_at, | |
| ] | |
| ) | |
| return response | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(queryset, request) | |
| serializer = ModelVersionSerializer(page, many=True) | |
| return paginator.get_paginated_response(serializer.data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Single version detail | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def model_version_detail(request, version_id): | |
| """Return detailed information about a single model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| serializer = ModelVersionSerializer(version) | |
| return Response(serializer.data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Publish to Hugging Face Hub (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def publish_to_hub(request, version_id): | |
| """Publish a model version to Hugging Face Hub. Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| if version.is_published_to_hub: | |
| return Response( | |
| {"error": "Already published", "hub_url": version.hub_repo_url}, status=400 | |
| ) | |
| try: | |
| publisher = HuggingFacePublisher(version) | |
| hub_url = publisher.publish() | |
| return Response({"hub_url": hub_url, "status": "published"}) | |
| except Exception as e: | |
| logger.exception(f"Publishing failed for {version_id}") | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Download model artifact | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def download_model_version(request, version_id): | |
| """ | |
| Download a model version. Prefers the compressed artifact if available, | |
| otherwise streams the artifact directory as a tar.gz. | |
| """ | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| # 1. Prefer compressed artifact | |
| if version.compressed_artifact_path and os.path.exists( | |
| version.compressed_artifact_path | |
| ): | |
| response = FileResponse( | |
| open(version.compressed_artifact_path, "rb"), | |
| as_attachment=True, | |
| content_type="application/gzip", | |
| ) | |
| ext = os.path.splitext(version.compressed_artifact_path)[1] or ".tar.gz" | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="model_v{version.version_number}{ext}"' | |
| ) | |
| return response | |
| # 2. Support single file download or glob resolution | |
| path = version.artifact_path | |
| if path and not os.path.exists(path): | |
| import glob | |
| # Try finding checkpoints inside or matching the path | |
| matches = glob.glob(os.path.join(path, "*.pth")) or glob.glob(path + "*") | |
| if matches: | |
| path = matches[0] | |
| if path and os.path.isfile(path): | |
| response = FileResponse( | |
| open(path, "rb"), | |
| as_attachment=True, | |
| content_type="application/octet-stream", | |
| ) | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="{os.path.basename(path)}"' | |
| ) | |
| return response | |
| # 3. If no compressed artifact and it's a directory, stream as tar.gz | |
| if not path or not os.path.isdir(path): | |
| raise Http404("Artifact not found") | |
| tar_buffer = io.BytesIO() | |
| with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: | |
| tar.add(path, arcname=os.path.basename(path)) | |
| tar_buffer.seek(0) | |
| response = FileResponse( | |
| tar_buffer, as_attachment=True, content_type="application/gzip" | |
| ) | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="model_v{version.version_number}.tar.gz"' | |
| ) | |
| return response | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Cleanup old versions (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def cleanup_versions(request, run_id): | |
| """Delete old versions, keeping the best K and any tagged versions. Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| run = get_object_or_404(Job, id=run_id) | |
| keep_best = int(request.data.get("keep_best_k", 3)) | |
| keep_tags = request.data.get("keep_version_tags", []) | |
| deleted_count = cleanup_old_versions( | |
| run, keep_best_k=keep_best, keep_version_tags=keep_tags | |
| ) | |
| return Response({"deleted": deleted_count}) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Archive to cold storage (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def archive_to_cold_storage(request, version_id): | |
| """Move a version's artifact to cold storage (S3 Glacier). Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| if version.storage_tier == "cold": | |
| return Response({"error": "Already in cold storage"}, status=400) | |
| bucket = getattr(settings, "MODEL_ARCHIVE_BUCKET", None) | |
| if not bucket: | |
| return Response({"error": "MODEL_ARCHIVE_BUCKET not configured"}, status=501) | |
| try: | |
| version.archive_to_cold_storage(bucket) | |
| return Response( | |
| {"status": "archived to cold storage", "storage_tier": version.storage_tier} | |
| ) | |
| except Exception as e: | |
| logger.exception(f"Archive failed for {version_id}") | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Restore from cold storage (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def restore_from_cold_storage(request, version_id): | |
| """Restore a version's artifact from cold storage. Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| if version.storage_tier != "cold": | |
| return Response({"error": "Not in cold storage"}, status=400) | |
| bucket = getattr(settings, "MODEL_ARCHIVE_BUCKET", None) | |
| if not bucket: | |
| return Response({"error": "MODEL_ARCHIVE_BUCKET not configured"}, status=501) | |
| try: | |
| import boto3 | |
| s3 = boto3.client("s3") | |
| key = f"run_{version.run.id}/v{version.version_number}/model.pth" | |
| local_path = os.path.join(version.artifact_path, "model.pth") | |
| os.makedirs(version.artifact_path, exist_ok=True) | |
| s3.download_file(bucket, key, local_path) | |
| version.storage_tier = "hot" | |
| version.save(update_fields=["storage_tier"]) | |
| return Response({"status": "restored from cold storage", "storage_tier": "hot"}) | |
| except Exception as e: | |
| logger.exception(f"Restore failed for {version_id}") | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Set as production version (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def set_production_version(request, run_id, version_number): | |
| """Mark a specific version as the production model for a run. Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| run = get_object_or_404(Job, id=run_id) | |
| version = get_object_or_404(ModelVersion, run=run, version_number=version_number) | |
| # Unset previous production flag | |
| run.versioning_model_versions.filter(is_production=True).update(is_production=False) | |
| version.is_production = True | |
| version.save(update_fields=["is_production"]) | |
| return Response( | |
| {"status": "production version updated", "version_number": version_number} | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Compare two versions | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def compare_versions(request, run_id, version_a, version_b): | |
| """Compare metrics and sizes between two versions of the same run.""" | |
| run = get_object_or_404(Job, id=run_id) | |
| v1 = get_object_or_404(ModelVersion, run=run, version_number=version_a) | |
| v2 = get_object_or_404(ModelVersion, run=run, version_number=version_b) | |
| metrics_diff = {} | |
| all_keys = set(v1.metrics.keys()) | set(v2.metrics.keys()) | |
| for key in all_keys: | |
| val1 = v1.metrics.get(key) | |
| val2 = v2.metrics.get(key) | |
| if isinstance(val1, (int, float)) and isinstance(val2, (int, float)): | |
| metrics_diff[key] = {"v1": val1, "v2": val2, "diff": round(val2 - val1, 6)} | |
| else: | |
| metrics_diff[key] = {"v1": val1, "v2": val2} | |
| return Response( | |
| { | |
| "version_a": { | |
| "id": str(v1.id), | |
| "version_number": v1.version_number, | |
| "version": v1.version, | |
| "created_at": v1.created_at, | |
| "size_mb": v1.artifact_size_mb, | |
| "compressed_size_mb": v1.compressed_size_mb, | |
| "compression_method": v1.compression_method, | |
| }, | |
| "version_b": { | |
| "id": str(v2.id), | |
| "version_number": v2.version_number, | |
| "version": v2.version, | |
| "created_at": v2.created_at, | |
| "size_mb": v2.artifact_size_mb, | |
| "compressed_size_mb": v2.compressed_size_mb, | |
| "compression_method": v2.compression_method, | |
| }, | |
| "metrics_diff": metrics_diff, | |
| "size_diff_mb": round(v2.artifact_size_mb - v1.artifact_size_mb, 2), | |
| "compressed_size_diff_mb": round( | |
| (v2.compressed_size_mb or 0) - (v1.compressed_size_mb or 0), 2 | |
| ), | |
| } | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Diff creation and reconstruction | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def create_diff_view(request, from_version_id, to_version_id): | |
| """Create a diff between two versions of the same run.""" | |
| v1 = get_object_or_404(ModelVersion, id=from_version_id) | |
| v2 = get_object_or_404(ModelVersion, id=to_version_id) | |
| if v1.run_id != v2.run_id: | |
| return Response({"error": "Versions must belong to the same run"}, status=400) | |
| try: | |
| diff = create_diff(v1, v2) | |
| return Response( | |
| { | |
| "diff_id": str(diff.id), | |
| "diff_size_mb": diff.diff_size_mb, | |
| "original_size_saved_mb": diff.original_size_saved_mb, | |
| }, | |
| status=201, | |
| ) | |
| except Exception as e: | |
| logger.exception("Diff creation failed") | |
| return Response({"error": str(e)}, status=500) | |
| def reconstruct_version(request, base_version_id, diff_id): | |
| """Reconstruct a model version by applying a diff to a base version.""" | |
| base = get_object_or_404(ModelVersion, id=base_version_id) | |
| diff = get_object_or_404(ModelVersionDiff, id=diff_id) | |
| try: | |
| model = load_version_from_diff(base, diff) | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| model.save_pretrained(tmpdir) | |
| tar_buffer = io.BytesIO() | |
| with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: | |
| tar.add(tmpdir, arcname="reconstructed") | |
| tar_buffer.seek(0) | |
| response = FileResponse( | |
| tar_buffer, as_attachment=True, content_type="application/gzip" | |
| ) | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="reconstructed_v{base.version_number}_from_diff.tar.gz"' | |
| ) | |
| return response | |
| except Exception as e: | |
| logger.exception("Reconstruction failed") | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Lineage graph | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def lineage_graph(request, version_id): | |
| """Return the full lineage graph for a model version.""" | |
| from .models import get_lineage_graph | |
| direction = request.query_params.get("direction", "both") | |
| max_depth = int(request.query_params.get("max_depth", 5)) | |
| graph = get_lineage_graph(str(version_id), direction=direction, max_depth=max_depth) | |
| return Response(graph) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Compression methods catalog | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def list_compression_methods(request): | |
| """List available compression methods from the framework.""" | |
| try: | |
| from framework.core.registry import COMPRESSOR_REGISTRY | |
| methods = { | |
| "none": "No compression", | |
| "gzip": "Gzip tar archive", | |
| } | |
| for key in COMPRESSOR_REGISTRY.keys(): | |
| methods[key] = f"Framework compressor: {key}" | |
| return Response(methods) | |
| except ImportError: | |
| return Response( | |
| { | |
| "none": "No compression", | |
| "gzip": "Gzip tar archive", | |
| } | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # Lifecycle migration (staff only) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def run_lifecycle_migration(request): | |
| """Manually trigger storage tier migration for all versions. Staff only.""" | |
| if not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| try: | |
| for version in ModelVersion.objects.all(): | |
| migrate_storage_tier(version) | |
| return Response({"status": "lifecycle migration completed"}) | |
| except Exception as e: | |
| logger.exception("Lifecycle migration failed") | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # GOVERNANCE — Approval Workflow | |
| # ═══════════════════════════════════════════════════════════════ | |
| def request_approval(request, version_id): | |
| """Submit a model version for approval.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| if ModelApproval.objects.filter(version=version).exists(): | |
| return Response( | |
| {"error": "Approval already requested for this version"}, status=400 | |
| ) | |
| approval = ModelApproval.objects.create( | |
| version=version, | |
| requested_by=request.user, | |
| notes=request.data.get("notes", ""), | |
| ) | |
| return Response(ModelApprovalSerializer(approval).data, status=201) | |
| def review_approval(request, approval_id): | |
| """Approve or reject a model version (staff / reviewer).""" | |
| approval = get_object_or_404(ModelApproval, id=approval_id) | |
| action = request.data.get("action") | |
| if action == "approve": | |
| approval.approve(request.user, request.data.get("notes", "")) | |
| elif action == "reject": | |
| approval.reject(request.user, request.data.get("notes", "")) | |
| else: | |
| return Response({"error": "action must be 'approve' or 'reject'"}, status=400) | |
| return Response(ModelApprovalSerializer(approval).data) | |
| def pending_approvals(request): | |
| """List all pending approval requests.""" | |
| qs = ModelApproval.objects.filter(status="pending").order_by("-created_at") | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(qs, request) | |
| return paginator.get_paginated_response( | |
| ModelApprovalSerializer(page, many=True).data | |
| ) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # GOVERNANCE — Access Control | |
| # ═══════════════════════════════════════════════════════════════ | |
| def grant_access(request, version_id): | |
| """Grant access to a model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| user_id = request.data.get("user_id") | |
| access_level = request.data.get("access_level", "read") | |
| user = get_object_or_404(User, id=user_id) | |
| access, created = ModelAccessControl.objects.update_or_create( | |
| version=version, | |
| user=user, | |
| defaults={ | |
| "access_level": access_level, | |
| "granted_by": request.user, | |
| }, | |
| ) | |
| return Response( | |
| ModelAccessControlSerializer(access).data, status=201 if created else 200 | |
| ) | |
| def revoke_access(request, access_id): | |
| """Revoke access to a model version.""" | |
| access = get_object_or_404(ModelAccessControl, id=access_id) | |
| access.delete() | |
| return Response(status=204) | |
| def version_access_list(request, version_id): | |
| """List all access grants for a model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| qs = version.access_controls.select_related("user", "granted_by").order_by( | |
| "-granted_at" | |
| ) | |
| return Response(ModelAccessControlSerializer(qs, many=True).data) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # GOVERNANCE — Security Scans | |
| # ═══════════════════════════════════════════════════════════════ | |
| def trigger_security_scan(request, version_id): | |
| """Trigger a security scan for a model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| scan_type = request.data.get("scan_type", "vulnerability") | |
| scan = ModelSecurityScan.objects.create( | |
| version=version, | |
| scan_type=scan_type, | |
| status="pending", | |
| ) | |
| # In production, trigger a Celery task to run the actual scan | |
| from .tasks import run_security_scan | |
| run_security_scan.delay(str(scan.id)) | |
| return Response(ModelSecurityScanSerializer(scan).data, status=201) | |
| def security_scan_results(request, version_id): | |
| """List security scan results for a model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| qs = version.security_scans.order_by("-scanned_at") | |
| return Response(ModelSecurityScanSerializer(qs, many=True).data) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # GOVERNANCE — Usage Audit Log | |
| # ═══════════════════════════════════════════════════════════════ | |
| def usage_log(request, version_id): | |
| """Get usage audit logs for a model version.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| qs = version.usage_logs.select_related("user").order_by("-created_at") | |
| action = request.query_params.get("action") | |
| if action: | |
| qs = qs.filter(action=action) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(qs, request) | |
| return paginator.get_paginated_response( | |
| ModelUsageLogSerializer(page, many=True).data | |
| ) | |
| def log_usage(request, version_id): | |
| """Log a model usage event.""" | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| action = request.data.get("action", "viewed") | |
| log_entry = ModelUsageLog.objects.create( | |
| version=version, | |
| user=request.user, | |
| action=action, | |
| metadata=request.data.get("metadata", {}), | |
| ip_address=request.META.get("REMOTE_ADDR"), | |
| ) | |
| return Response(ModelUsageLogSerializer(log_entry).data, status=201) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # GOVERNANCE — Dashboard | |
| # ═══════════════════════════════════════════════════════════════ | |
| def governance_dashboard(request): | |
| """Governance dashboard showing all models, approvals, and security status.""" | |
| total_models = ModelVersion.objects.count() | |
| pending = ModelApproval.objects.filter(status="pending").count() | |
| approved = ModelApproval.objects.filter(status="approved").count() | |
| rejected = ModelApproval.objects.filter(status="rejected").count() | |
| high_severity = ModelSecurityScan.objects.filter( | |
| severity__in=["high", "critical"] | |
| ).count() | |
| recent_usage = ModelUsageLog.objects.order_by("-created_at")[:20] | |
| return Response( | |
| { | |
| "total_models": total_models, | |
| "approvals": { | |
| "pending": pending, | |
| "approved": approved, | |
| "rejected": rejected, | |
| }, | |
| "high_severity_issues": high_severity, | |
| "recent_usage": ModelUsageLogSerializer(recent_usage, many=True).data, | |
| } | |
| ) | |
| def create_branch(request): | |
| """Create a new branch from a specific model version.""" | |
| version_id = request.data.get("version_id") | |
| name = request.data.get("name") | |
| if not version_id or not name: | |
| return Response({"error": "version_id and name required"}, status=400) | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| branch, created = ModelBranch.objects.get_or_create( | |
| name=name, root_version=version, defaults={"created_by": request.user} | |
| ) | |
| return Response({"branch_id": str(branch.id), "name": branch.name}, status=201) | |
| def commit_to_branch(request, branch_id): | |
| """Create a new commit on a branch, linking a model version.""" | |
| branch = get_object_or_404(ModelBranch, id=branch_id) | |
| version_id = request.data.get("version_id") | |
| message = request.data.get("message", "") | |
| version = get_object_or_404(ModelVersion, id=version_id) | |
| last_commit = branch.commits.order_by("-created_at").first() | |
| commit = ModelCommit.objects.create( | |
| branch=branch, | |
| version=version, | |
| parent=last_commit, | |
| message=message, | |
| created_by=request.user, | |
| ) | |
| return Response({"commit_id": str(commit.id)}, status=201) | |
| def merge_branches(request): | |
| """Merge two branches into a new commit on the target branch.""" | |
| source_branch_id = request.data.get("source_branch_id") | |
| target_branch_id = request.data.get("target_branch_id") | |
| message = request.data.get("message", "Merge") | |
| source = get_object_or_404(ModelBranch, id=source_branch_id) | |
| target = get_object_or_404(ModelBranch, id=target_branch_id) | |
| # The merge result is a new version that is the combination of the two branch tips. | |
| # In practice, you'd need a merging algorithm; here we just create a commit with two parents. | |
| target_last = target.commits.order_by("-created_at").first() | |
| source_last = source.commits.order_by("-created_at").first() | |
| if not target_last or not source_last: | |
| return Response( | |
| {"error": "Both branches must have at least one commit"}, status=400 | |
| ) | |
| # For simplicity, the new version is the target's latest version (could be a custom merged version) | |
| merge_commit = ModelCommit.objects.create( | |
| branch=target, | |
| version=target_last.version, # placeholder | |
| parent=target_last, | |
| merge_parent=source_last, | |
| message=message, | |
| created_by=request.user, | |
| ) | |
| return Response({"commit_id": str(merge_commit.id)}, status=201) | |
| def model_timeline(request): | |
| """Return all commits across all branches for a root model, ordered by time.""" | |
| root_version_id = request.query_params.get("root_version_id") | |
| if not root_version_id: | |
| return Response({"error": "root_version_id required"}, status=400) | |
| # Find all branches that share this root | |
| branches = ModelBranch.objects.filter(root_version_id=root_version_id) | |
| commits = ( | |
| ModelCommit.objects.filter(branch__in=branches) | |
| .select_related("version", "branch", "parent", "merge_parent") | |
| .order_by("created_at") | |
| ) | |
| data = [] | |
| for c in commits: | |
| data.append( | |
| { | |
| "id": str(c.id), | |
| "branch": c.branch.name, | |
| "version_id": str(c.version.id), | |
| "version_number": c.version.version_number, | |
| "message": c.message, | |
| "parent": str(c.parent_id) if c.parent_id else None, | |
| "merge_parent": str(c.merge_parent_id) if c.merge_parent_id else None, | |
| "created_at": c.created_at.isoformat(), | |
| } | |
| ) | |
| return Response(data) | |
Xet Storage Details
- Size:
- 36.3 kB
- Xet hash:
- 8b7ed1c6106982d841288e5b7bd073a0bd217639b65549e412372aa12fe91d21
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.