Skip to content
Closed
31 changes: 31 additions & 0 deletions app/api/files/migrations/0002_add_deleted_at_to_filedownload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.2.1 on 2025-10-08 16:13

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("files", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="filedownload",
name="deleted_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name="filedownload",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="filetype",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]
2 changes: 2 additions & 0 deletions app/api/files/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class FileDownload(BaseModel):
user (User, optional): The user who generated the file. Defaults to None.
file_type (FileType): The type of the file.
file_url (str, optional): The URL for downloading the file. Defaults to None.
deleted_at (datetime, optional): Timestamp when the file was deleted. Defaults to None.

Returns:
str: The name of the file.
Expand All @@ -55,6 +56,7 @@ class FileDownload(BaseModel):
)
file_type = models.ForeignKey(FileType, on_delete=models.CASCADE)
file_url = models.CharField(max_length=500, null=True, blank=True)
deleted_at = models.DateTimeField(null=True, blank=True)
Comment thread
AndrewThien marked this conversation as resolved.

def __str__(self):
return str(self.name)
63 changes: 59 additions & 4 deletions app/api/files/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import json
import os
from datetime import timedelta

from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from jobs.models import Job, JobStage, StageStatus
from mapping.models import ScanReport
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.mixins import DestroyModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.permissions import IsAuthenticated
from services.storage_service import StorageService
from services.worker_service import get_worker_service
Expand All @@ -22,13 +25,17 @@
worker_service = get_worker_service()


class FileDownloadView(GenericAPIView, ListModelMixin, RetrieveModelMixin):
class FileDownloadView(
GenericAPIView, ListModelMixin, RetrieveModelMixin, DestroyModelMixin
):
"""
A view for handling file downloads and file generation requests.
This view provides functionality to:
- Retrieve a list of downloadable files associated with a specific scan report.
- Download a specific file by its primary key.
- Request the generation of a file for download by sending a message to a queue.
- Delete a file manually from storage and database.
- Automatically filter files older than FILE_RETENTION_DAYS from the list.
Attributes:
serializer_class (Serializer): The serializer class used for file downloads.
filter_backends (list): The list of filter backends for filtering querysets.
Expand All @@ -37,12 +44,15 @@ class FileDownloadView(GenericAPIView, ListModelMixin, RetrieveModelMixin):
ordering (str): The default ordering for querysets.
Methods:
get_queryset():
Retrieves the queryset of FileDownload objects filtered by the scan report ID.
Retrieves the queryset of FileDownload objects filtered by the scan report ID
and age (files older than FILE_RETENTION_DAYS are excluded).
get(request, *args, **kwargs):
Handles GET requests. If a primary key is provided, it downloads the file.
Otherwise, it returns a paginated list of files.
post(request, *args, **kwargs):
Handles POST requests to request the generation of a file for download.
delete(request, *args, **kwargs):
Handles DELETE requests to manually remove a file from storage and database.
"""

serializer_class = FileDownloadSerializer
Expand All @@ -56,7 +66,16 @@ def get_queryset(self):
scan_report_id = self.kwargs["scanreport_pk"]
scan_report = get_object_or_404(ScanReport, pk=scan_report_id)

return FileDownload.objects.filter(scan_report=scan_report)
# Hide files older than retention period (default: 30 days)
retention_days = int(os.getenv("FILE_RETENTION_DAYS", "30"))
cutoff_date = timezone.now() - timedelta(days=retention_days)

# Only show recent, non-deleted files
return FileDownload.objects.filter(
scan_report=scan_report,
created_at__gte=cutoff_date,
Comment thread
AndrewThien marked this conversation as resolved.
Outdated
deleted_at__isnull=True,
)

def get(self, request, *args, **kwargs):
if "pk" in kwargs:
Expand Down Expand Up @@ -151,3 +170,39 @@ def post(self, request, *args, **kwargs):
return JsonResponse({"error": "Internal server error."}, status=500)

return HttpResponse(status=202)

def delete(self, request, *args, **kwargs):
"""
Handles DELETE requests to soft-delete a file from storage.

This allows users to immediately delete unwanted files before the automatic
retention period expires. The physical file is removed from storage (Azure/MinIO)
but the database record is kept for audit purposes with a deleted_at timestamp.

Args:
request: The HTTP request object.
*args: Variable length argument list.
**kwargs: Must contain 'pk' - the primary key of the FileDownload to delete.

Returns:
- 204 No Content: If the file is successfully deleted.
- 404 Not Found: If the file doesn't exist.
- 500 Internal Server Error: If an error occurs during deletion.
"""
try:
file_download = get_object_or_404(FileDownload, pk=kwargs["pk"])

# Delete the physical file from storage if file_url exists
if file_download.file_url:
try:
storage_service.delete_file(file_download.file_url, "rules-exports")
except Exception:
pass # File may not exist in storage

# Mark as deleted but keep record for audit
file_download.deleted_at = timezone.now()
file_download.save()

return HttpResponse(status=204)
except Exception:
return JsonResponse({"error": "Internal server error."}, status=500)
17 changes: 17 additions & 0 deletions app/next-client-app/api/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const fetchKeys = {
file_id
? `v2/scanreports/${scan_report_id}/rules/downloads/${file_id}/`
: `v2/scanreports/${scan_report_id}/download/`,
deleteFile: (scan_report_id: number, file_id: number) =>
`v2/scanreports/${scan_report_id}/rules/downloads/${file_id}/`,
Comment thread
AndrewThien marked this conversation as resolved.
Outdated
};

export async function list(
Expand Down Expand Up @@ -79,3 +81,18 @@ export async function downloadFile(
return { success: false, errorMessage: error.message };
}
}

export async function deleteFile(
scan_report_id: number,
file_id: number,
): Promise<{ success: boolean; errorMessage?: string }> {
try {
await request(fetchKeys.deleteFile(scan_report_id, file_id), {
method: "DELETE",
});
revalidatePath(`/scanreports/${scan_report_id}/downloads`);
return { success: true };
} catch (error: any) {
return { success: false, errorMessage: error.message };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { saveAs } from "file-saver";
import { Badge } from "@/components/ui/badge";
import { downloadFile } from "@/api/files";
import { toast } from "sonner";
import DeleteFileDialog from "@/components/files/DeleteFileDialog";

export const columns: ColumnDef<FileDownload>[] = [
{
Expand Down Expand Up @@ -92,4 +93,21 @@ export const columns: ColumnDef<FileDownload>[] = [
enableHiding: true,
enableSorting: false,
},
{
id: "Delete",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
const { id, scan_report, name } = row.original;
return (
<DeleteFileDialog
fileId={id}
scanReportId={scan_report}
fileName={name}
needTrigger={true}
/>
);
},
enableHiding: true,
enableSorting: false
}
];
81 changes: 81 additions & 0 deletions app/next-client-app/components/files/DeleteFileDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "../ui/dialog";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { deleteFile } from "@/api/files";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";

interface DeleteFileDialogProps {
fileId: number;
scanReportId: number;
fileName: string;
isOpen?: boolean;
setOpen?: (isOpen: boolean) => void;
needTrigger?: boolean;
}

const DeleteFileDialog = ({
fileId,
scanReportId,
fileName,
isOpen,
setOpen = () => {},
needTrigger = false
}: DeleteFileDialogProps) => {
const router = useRouter();

const handleDelete = async () => {
const response = await deleteFile(scanReportId, fileId);
if (response.success) {
toast.success(`File "${fileName}" deleted successfully`);
router.refresh();
} else {
toast.error(
`Failed to delete file: ${response.errorMessage || "Unknown error"}`
);
}
setOpen(false);
};

return (
<Dialog open={isOpen} onOpenChange={() => setOpen(false)}>
{needTrigger && (
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader className="text-start">
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Comment thread
AndrewThien marked this conversation as resolved.
Outdated
Are you sure you want to delete "{fileName}"? This action cannot be
undone and will permanently remove the file from storage.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col space-y-2 sm:space-y-0 sm:space-x-2">
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default DeleteFileDialog;
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ services:
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- FILE_RETENTION_DAYS=${FILE_RETENTION_DAYS:-30}
Comment thread
AndrewThien marked this conversation as resolved.
Outdated
- WORKER_SERVICE_TYPE=airflow
# TODO: update to API v2 when updating Airflow to 3.0.0
- AIRFLOW_BASE_URL=http://airflow-webserver:8080/api/v1/
Expand Down