Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
Member

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
Member

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)
60 changes: 56 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,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 FILE_RETENTION_DAYS
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 +166,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
@@ -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 { FILE_RETENTION_DAYS } from "@/constants/config";

interface FileDownloadsTableProps {
data: FileDownload[];
count: number;
columns: ColumnDef<FileDownload>[];
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() - FILE_RETENTION_DAYS * 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 = (
<div className="flex gap-2 items-center">
{Filter}
{oldFiles.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowOldFiles(!showOldFiles)}
className="ml-auto"
>
{showOldFiles ? (
<>
<EyeOff className="mr-2 h-4 w-4" />
Hide older files
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Show {oldFiles.length} older file
{oldFiles.length !== 1 ? "s" : ""}
</>
)}
</Button>
)}
</div>
);

return (
<DataTable
columns={columns}
data={displayedFiles}
count={displayedCount}
Filter={filterComponent}
defaultPageSize={defaultPageSize}
/>
);
}
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 "./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
Loading