diff --git a/app/api/api/urls.py b/app/api/api/urls.py index 161124483..aecf0593e 100644 --- a/app/api/api/urls.py +++ b/app/api/api/urls.py @@ -79,6 +79,11 @@ FileDownloadView.as_view(), name="scan-report-downloads-get", ), + path( + "v2/scanreports//rules/delete//", + FileDownloadView.as_view(), + name="delete-rules-export-file", + ), path( "v2/scanreports//tables//", views.ScanReportTableDetailV2.as_view(), diff --git a/app/api/files/migrations/0002_filedownload_deleted_at_filedownload_deleted_by_and_more.py b/app/api/files/migrations/0002_filedownload_deleted_at_filedownload_deleted_by_and_more.py new file mode 100644 index 000000000..9f5dbdd00 --- /dev/null +++ b/app/api/files/migrations/0002_filedownload_deleted_at_filedownload_deleted_by_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.1 on 2025-10-09 12:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("files", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="filedownload", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="filedownload", + name="deleted_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deleted_files", + to=settings.AUTH_USER_MODEL, + ), + ), + 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" + ), + ), + ] diff --git a/app/api/files/models.py b/app/api/files/models.py index 9f7aab82c..2844c448a 100644 --- a/app/api/files/models.py +++ b/app/api/files/models.py @@ -40,6 +40,8 @@ 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. + deleted_by (User, optional): The user who deleted the file. Defaults to None. Returns: str: The name of the file. @@ -55,6 +57,14 @@ 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) + deleted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="deleted_files", + ) def __str__(self): return str(self.name) diff --git a/app/api/files/views.py b/app/api/files/views.py index 10bc67360..f78822d69 100644 --- a/app/api/files/views.py +++ b/app/api/files/views.py @@ -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 @@ -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. + - Return all non-deleted files (frontend handles auto-hiding based on age). Attributes: serializer_class (Serializer): The serializer class used for file downloads. filter_backends (list): The list of filter backends for filtering querysets. @@ -37,12 +44,14 @@ 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 all non-deleted FileDownload objects for a scan report. 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 @@ -56,7 +65,12 @@ 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) + # Return all non-deleted files + # Frontend will handle hiding files older than OLD_FILE_THRESHOLD + return FileDownload.objects.filter( + scan_report=scan_report, + deleted_at__isnull=True, + ) def get(self, request, *args, **kwargs): if "pk" in kwargs: @@ -151,3 +165,40 @@ 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.deleted_by = request.user + file_download.save() + + return HttpResponse(status=204) + except Exception: + return JsonResponse({"error": "Internal server error."}, status=500) diff --git a/app/api/uv.lock b/app/api/uv.lock index 5a36a41a1..e45a02f81 100644 --- a/app/api/uv.lock +++ b/app/api/uv.lock @@ -1,10 +1,10 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <4" [[package]] name = "api" -version = "4.2.2" +version = "4.2.4" source = { editable = "." } dependencies = [ { name = "azure-common" }, diff --git a/app/next-client-app/api/files.ts b/app/next-client-app/api/files.ts index 912ceab60..4455e16c2 100644 --- a/app/next-client-app/api/files.ts +++ b/app/next-client-app/api/files.ts @@ -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/delete/${file_id}/`, }; export async function list( @@ -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 }; + } +} diff --git a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx index 9e3a9d057..70ca4c519 100644 --- a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx +++ b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/columns.tsx @@ -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[] = [ { @@ -92,4 +93,21 @@ export const columns: ColumnDef[] = [ enableHiding: true, enableSorting: false, }, + { + id: "Delete", + header: ({ column }) => , + cell: ({ row }) => { + const { id, scan_report, name } = row.original; + return ( + + ); + }, + enableHiding: true, + enableSorting: false + } ]; diff --git a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/page.tsx b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/page.tsx index fa04c4030..ef4c0d25b 100644 --- a/app/next-client-app/app/(protected)/scanreports/[id]/downloads/page.tsx +++ b/app/next-client-app/app/(protected)/scanreports/[id]/downloads/page.tsx @@ -1,9 +1,9 @@ import { objToQuery } from "@/lib/client-utils"; -import { DataTable } from "@/components/data-table"; import { list } from "@/api/files"; import { columns } from "./columns"; import { getJobs } from "@/api/scanreports"; import { DownloadStatus } from "./download-status"; +import { FileDownloadsTable } from "@/components/files/FileDownloadsTable"; interface DownloadsProps { params: Promise<{ @@ -16,14 +16,12 @@ export default async function Downloads(props: DownloadsProps) { const searchParams = await props.searchParams; const params = await props.params; - const { - id - } = params; + const { id } = params; const defaultPageSize = 20; const defaultParams = { p: 1, - page_size: defaultPageSize, + page_size: defaultPageSize }; const combinedParams = { ...defaultParams, ...searchParams }; const query = objToQuery(combinedParams); @@ -39,7 +37,7 @@ export default async function Downloads(props: DownloadsProps) { return (
{filesList && ( - void; + needTrigger?: boolean; +} + +const DeleteFileDialog = ({ + fileId, + scanReportId, + fileName, + isOpen, + setOpen, + needTrigger = false +}: DeleteFileDialogProps) => { + const router = useRouter(); + const [internalOpen, setInternalOpen] = useState(false); + const dialogOpen = isOpen !== undefined ? isOpen : internalOpen; + const setDialogOpen = setOpen || setInternalOpen; + + const handleDelete = async () => { + const response = await deleteFile(scanReportId, fileId); + if (response.success) { + toast.success(`File "${fileName}" deleted successfully`); + router.refresh(); + setDialogOpen(false); + } else { + toast.error( + `Failed to delete file: ${response.errorMessage || "Unknown error"}` + ); + } + }; + + return ( + + {needTrigger && ( + + + + )} + + + Delete File + +

+ Are you sure you want to delete "{fileName}"? This action cannot be + undone and will permanently remove the file from storage. +

+ + + + + + +
+
+ ); +}; + +export default DeleteFileDialog; diff --git a/app/next-client-app/components/files/FileDownloadsTable.tsx b/app/next-client-app/components/files/FileDownloadsTable.tsx new file mode 100644 index 000000000..bb94896f1 --- /dev/null +++ b/app/next-client-app/components/files/FileDownloadsTable.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@/components/data-table"; +import { Button } from "@/components/ui/button"; +import { Eye, EyeOff } from "lucide-react"; +import { OLD_FILE_THRESHOLD } from "@/constants/config"; + +interface FileDownloadsTableProps { + data: FileDownload[]; + count: number; + columns: ColumnDef[]; + Filter?: JSX.Element; + defaultPageSize: 10 | 20 | 30 | 40 | 50; +} + +export function FileDownloadsTable({ + data, + count, + columns, + Filter, + defaultPageSize +}: FileDownloadsTableProps) { + const [showOldFiles, setShowOldFiles] = useState(false); + + const cutoffDate = useMemo(() => { + const millisecondsPerDay = 24 * 60 * 60 * 1000; + return new Date(Date.now() - OLD_FILE_THRESHOLD * millisecondsPerDay); + }, []); + + const { recentFiles, oldFiles } = useMemo(() => { + const recent: FileDownload[] = []; + const old: FileDownload[] = []; + + data.forEach((file) => { + const fileDate = new Date(file.created_at); + if (fileDate >= cutoffDate) { + recent.push(file); + } else { + old.push(file); + } + }); + + return { recentFiles: recent, oldFiles: old }; + }, [data, cutoffDate]); + + const displayedFiles = showOldFiles ? data : recentFiles; + const displayedCount = showOldFiles ? count : recentFiles.length; + + const filterComponent = ( +
+ {Filter} + {oldFiles.length > 0 && ( + + )} +
+ ); + + return ( + + ); +} diff --git a/app/next-client-app/constants/config.js b/app/next-client-app/constants/config.js index d67a72853..40f5e9f4b 100644 --- a/app/next-client-app/constants/config.js +++ b/app/next-client-app/constants/config.js @@ -3,6 +3,12 @@ const MAX_FILE_SIZE_BYTES = process.env.NEXT_PUBLIC_BODY_SIZE_LIMIT ? parseInt(process.env.NEXT_PUBLIC_BODY_SIZE_LIMIT, 10) : 31457280; // 30MB in bytes +// Old file threshold in days - files older than this are auto-hidden in the UI +const OLD_FILE_THRESHOLD = process.env.NEXT_PUBLIC_OLD_FILE_THRESHOLD + ? parseFloat(process.env.NEXT_PUBLIC_OLD_FILE_THRESHOLD) + : 30; + module.exports = { MAX_FILE_SIZE_BYTES, + OLD_FILE_THRESHOLD, }; \ No newline at end of file diff --git a/app/next-client-app/next-env.d.ts b/app/next-client-app/next-env.d.ts index 830fb594c..1b3be0840 100644 --- a/app/next-client-app/next-env.d.ts +++ b/app/next-client-app/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docker-compose.yml b/docker-compose.yml index 38439dcbf..65384c71a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,7 @@ services: - RECOMMENDATION_SERVICE_BASE_URL=https://api.hyperunison.com/api/public/suggester/generate - RECOMMENDATION_SERVICE_API_KEY=unison-api-key - NEXT_PUBLIC_BODY_SIZE_LIMIT=31457280 + - NEXT_PUBLIC_OLD_FILE_THRESHOLD=30 volumes: - ./app/next-client-app:/app - /app/node_modules