Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions app/api/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@
FileDownloadView.as_view(),
name="scan-report-downloads-get",
),
path(
"v2/scanreports/<int:scanreport_pk>/rules/delete/<int:pk>/",
FileDownloadView.as_view(),
name="delete-rules-export-file",
),
path(
"v2/scanreports/<int:pk>/tables/<int:table_pk>/",
views.ScanReportTableDetailV2.as_view(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
),
),
]
10 changes: 10 additions & 0 deletions app/api/files/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Comment thread
AndrewThien marked this conversation as resolved.
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to decide the behaviour when the associated user is deleted: either keeping the file for record (as you did here) or deleting the associated files as well (as @AndyRae did in line 54)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the above is about the case when the associated users are deleted. Anyway, we can come back to this later when Andy comes back (I don't think this is a blocker for other PRs), or you can decide as the PR author. I just want the deleting behaviour to be consistent.

blank=True,
null=True,
related_name="deleted_files",
)

def __str__(self):
return str(self.name)
59 changes: 55 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.
- 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.
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
4 changes: 2 additions & 2 deletions app/api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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/delete/${file_id}/`,
};

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
}
];
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -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);
Expand All @@ -39,7 +37,7 @@ export default async function Downloads(props: DownloadsProps) {
return (
<div>
{filesList && (
<DataTable
<FileDownloadsTable
Comment thread
AndrewThien marked this conversation as resolved.
columns={columns}
data={filesList.results}
count={filesList.count}
Expand Down
84 changes: 84 additions & 0 deletions app/next-client-app/components/files/DeleteFileDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import {
Dialog,
DialogClose,
DialogContent,
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";
import { useState } from "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 [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 (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{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>
</DialogHeader>
<p className="text-sm">
Are you sure you want to delete "{fileName}"? This action cannot be
undone and will permanently remove the file from storage.
</p>
<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;
Loading