diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index c0b10d673e..35792444e9 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -228,6 +228,12 @@ class AttachmentMixin:
filter_backends = SEARCH_ORDER_FILTER
+ search_fields = [
+ 'attachment',
+ 'comment',
+ 'link',
+ ]
+
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 91f695b72c..7859a8a3db 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,11 +2,14 @@
# InvenTree API version
-INVENTREE_API_VERSION = 133
+INVENTREE_API_VERSION = 134
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525
+ - Allow "Attachment" list endpoints to be searched by attachment, link and comment fields
+
v133 -> 2023-09-08 : https://github.com/inventree/InvenTree/pull/5518
- Add extra optional fields which can be used for StockAdjustment endpoints
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 3575f32ed7..acadb88a16 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -583,10 +583,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
- filter_backends = [
- DjangoFilterBackend,
- ]
-
filterset_fields = [
'build',
]
diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py
index 05638e1398..d2268cb961 100644
--- a/InvenTree/company/api.py
+++ b/InvenTree/company/api.py
@@ -4,7 +4,6 @@ from django.db.models import Q
from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters
-from django_filters.rest_framework import DjangoFilterBackend
import part.models
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
@@ -89,10 +88,6 @@ class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
- filter_backends = [
- DjangoFilterBackend,
- ]
-
filterset_fields = [
'company',
]
@@ -246,10 +241,6 @@ class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
- filter_backends = [
- DjangoFilterBackend,
- ]
-
filterset_fields = [
'manufacturer_part',
]
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 99f04c5b2a..f11414f202 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -583,10 +583,6 @@ class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SalesOrderAttachmentSerializer
- filter_backends = [
- rest_filters.DjangoFilterBackend,
- ]
-
filterset_fields = [
'order',
]
@@ -1079,10 +1075,6 @@ class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.PurchaseOrderAttachmentSerializer
- filter_backends = [
- rest_filters.DjangoFilterBackend,
- ]
-
filterset_fields = [
'order',
]
@@ -1359,10 +1351,6 @@ class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.ReturnOrderAttachment.objects.all()
serializer_class = serializers.ReturnOrderAttachmentSerializer
- filter_backends = [
- rest_filters.DjangoFilterBackend,
- ]
-
filterset_fields = [
'order',
]
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 60f371af56..7553112c34 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -326,10 +326,6 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
- filter_backends = [
- DjangoFilterBackend,
- ]
-
filterset_fields = [
'part',
]
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 961ac7149a..519735a98f 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -1051,8 +1051,6 @@ class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = StockItemAttachment.objects.all()
serializer_class = StockSerializers.StockItemAttachmentSerializer
- filter_backends = SEARCH_ORDER_FILTER
-
filterset_fields = [
'stock_item',
]
diff --git a/src/frontend/src/components/items/AttachmentLink.tsx b/src/frontend/src/components/items/AttachmentLink.tsx
new file mode 100644
index 0000000000..8a27a29f2f
--- /dev/null
+++ b/src/frontend/src/components/items/AttachmentLink.tsx
@@ -0,0 +1,63 @@
+import { Group, Text } from '@mantine/core';
+import { IconFileTypeJpg, IconPhoto } from '@tabler/icons-react';
+import {
+ IconFile,
+ IconFileTypeCsv,
+ IconFileTypeDoc,
+ IconFileTypePdf,
+ IconFileTypeXls,
+ IconFileTypeZip
+} from '@tabler/icons-react';
+import { ReactNode } from 'react';
+
+/**
+ * Return an icon based on the provided filename
+ */
+export function attachmentIcon(attachment: string): ReactNode {
+ const sz = 18;
+ let suffix = attachment.split('.').pop()?.toLowerCase() ?? '';
+ switch (suffix) {
+ case 'pdf':
+ return ;
+ case 'csv':
+ return ;
+ case 'xls':
+ case 'xlsx':
+ return ;
+ case 'doc':
+ case 'docx':
+ return ;
+ case 'zip':
+ case 'tar':
+ case 'gz':
+ case '7z':
+ return ;
+ case 'png':
+ case 'jpg':
+ case 'jpeg':
+ case 'gif':
+ case 'bmp':
+ case 'tif':
+ case 'webp':
+ return ;
+ default:
+ return ;
+ }
+}
+
+/**
+ * Render a link to a file attachment, with icon and text
+ * @param attachment : string - The attachment filename
+ */
+export function AttachmentLink({
+ attachment
+}: {
+ attachment: string;
+}): ReactNode {
+ return (
+
+ {attachmentIcon(attachment)}
+ {attachment.split('/').pop()}
+
+ );
+}
diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx
index 57106a2c62..3c1bc560b6 100644
--- a/src/frontend/src/components/nav/PanelGroup.tsx
+++ b/src/frontend/src/components/nav/PanelGroup.tsx
@@ -1,4 +1,4 @@
-import { Tabs } from '@mantine/core';
+import { Divider, Paper, Stack, Tabs, Text } from '@mantine/core';
import { ReactNode } from 'react';
import { useEffect, useState } from 'react';
@@ -78,7 +78,13 @@ export function PanelGroup({
(panel, idx) =>
!panel.hidden && (
- {panel.content}
+
+
+ {panel.label}
+
+ {panel.content}
+
+
)
)}
diff --git a/src/frontend/src/components/tables/AttachmentTable.tsx b/src/frontend/src/components/tables/AttachmentTable.tsx
new file mode 100644
index 0000000000..d60a92de5e
--- /dev/null
+++ b/src/frontend/src/components/tables/AttachmentTable.tsx
@@ -0,0 +1,248 @@
+import { t } from '@lingui/macro';
+import { Badge, Group, Stack, Text, Tooltip } from '@mantine/core';
+import { ActionIcon } from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
+import { useId } from '@mantine/hooks';
+import { notifications } from '@mantine/notifications';
+import { IconExternalLink, IconFileUpload } from '@tabler/icons-react';
+import { ReactNode, useEffect, useMemo, useState } from 'react';
+
+import { api } from '../../App';
+import {
+ addAttachment,
+ deleteAttachment,
+ editAttachment
+} from '../../functions/forms/AttachmentForms';
+import { useTableRefresh } from '../../hooks/TableRefresh';
+import { AttachmentLink } from '../items/AttachmentLink';
+import { TableColumn } from './Column';
+import { InvenTreeTable } from './InvenTreeTable';
+import { RowAction } from './RowActions';
+
+/**
+ * Define set of columns to display for the attachment table
+ */
+function attachmentTableColumns(): TableColumn[] {
+ return [
+ {
+ accessor: 'attachment',
+ title: t`Attachment`,
+ sortable: false,
+ switchable: false,
+ noWrap: true,
+ render: function (record: any) {
+ if (record.attachment) {
+ return ;
+ } else if (record.link) {
+ // TODO: Custom renderer for links
+ return record.link;
+ } else {
+ return '-';
+ }
+ }
+ },
+ {
+ accessor: 'comment',
+ title: t`Comment`,
+ sortable: false,
+ switchable: true,
+ render: function (record: any) {
+ return record.comment;
+ }
+ },
+ {
+ accessor: 'uploaded',
+ title: t`Uploaded`,
+ sortable: false,
+ switchable: true,
+ render: function (record: any) {
+ return (
+
+ {record.upload_date}
+ {record.user_detail && (
+ {record.user_detail.username}
+ )}
+
+ );
+ }
+ }
+ ];
+}
+
+/**
+ * Construct a table for displaying uploaded attachments
+ */
+export function AttachmentTable({
+ url,
+ model,
+ pk
+}: {
+ url: string;
+ pk: number;
+ model: string;
+}): ReactNode {
+ const tableId = useId();
+
+ const { refreshId, refreshTable } = useTableRefresh();
+
+ const tableColumns = useMemo(() => attachmentTableColumns(), []);
+
+ const [allowEdit, setAllowEdit] = useState(false);
+ const [allowDelete, setAllowDelete] = useState(false);
+
+ // Determine which permissions are available for this URL
+ useEffect(() => {
+ api
+ .options(url)
+ .then((response) => {
+ let actions: any = response.data?.actions ?? {};
+
+ setAllowEdit('POST' in actions);
+ setAllowDelete('DELETE' in actions);
+ return response;
+ })
+ .catch((error) => {
+ return error;
+ });
+ }, []);
+
+ // Construct row actions for the attachment table
+ function rowActions(record: any): RowAction[] {
+ let actions: RowAction[] = [];
+
+ if (allowEdit) {
+ actions.push({
+ title: t`Edit`,
+ onClick: () => {
+ editAttachment({
+ url: url,
+ model: model,
+ pk: record.pk,
+ attachmentType: record.attachment ? 'file' : 'link',
+ callback: refreshTable
+ });
+ }
+ });
+ }
+
+ if (allowDelete) {
+ actions.push({
+ title: t`Delete`,
+ color: 'red',
+ onClick: () => {
+ deleteAttachment({
+ url: url,
+ pk: record.pk,
+ callback: refreshTable
+ });
+ }
+ });
+ }
+
+ return actions;
+ }
+
+ // Callback to upload file attachment(s)
+ function uploadFiles(files: File[]) {
+ files.forEach((file) => {
+ let formData = new FormData();
+ formData.append('attachment', file);
+ formData.append(model, pk.toString());
+
+ api
+ .post(url, formData)
+ .then((response) => {
+ notifications.show({
+ title: t`File uploaded`,
+ message: t`File ${file.name} uploaded successfully`,
+ color: 'green'
+ });
+
+ refreshTable();
+
+ return response;
+ })
+ .catch((error) => {
+ console.error('error uploading attachment:', file, '->', error);
+ notifications.show({
+ title: t`Upload Error`,
+ message: t`File could not be uploaded`,
+ color: 'red'
+ });
+ return error;
+ });
+ });
+ }
+
+ function customActionGroups(): ReactNode[] {
+ let actions = [];
+
+ if (allowEdit) {
+ actions.push(
+
+ {
+ addAttachment({
+ url: url,
+ model: model,
+ pk: pk,
+ attachmentType: 'file',
+ callback: refreshTable
+ });
+ }}
+ >
+
+
+
+ );
+
+ actions.push(
+
+ {
+ addAttachment({
+ url: url,
+ model: model,
+ pk: pk,
+ attachmentType: 'link',
+ callback: refreshTable
+ });
+ }}
+ >
+
+
+
+ );
+ }
+
+ return actions;
+ }
+
+ return (
+
+
+ {allowEdit && (
+
+
+
+
+ {t`Upload attachment`}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx
index 06355fc1bb..6fd4c4f10b 100644
--- a/src/frontend/src/components/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/components/tables/InvenTreeTable.tsx
@@ -5,7 +5,7 @@ import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ButtonMenu } from '../items/ButtonMenu';
@@ -98,7 +98,8 @@ export function InvenTreeTable({
barcodeActions = [],
customActionGroups = [],
customFilters = [],
- rowActions
+ rowActions,
+ refreshId
}: {
url: string;
params: any;
@@ -118,10 +119,8 @@ export function InvenTreeTable({
customActionGroups?: any[];
customFilters?: TableFilter[];
rowActions?: (record: any) => RowAction[];
+ refreshId?: string;
}) {
- // Data columns
- const [dataColumns, setDataColumns] = useState(columns);
-
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some(
(col: TableColumn) => col.switchable
@@ -132,10 +131,17 @@ export function InvenTreeTable({
loadHiddenColumns(tableKey)
);
+ // Data selection
+ const [selectedRecords, setSelectedRecords] = useState([]);
+
+ function onSelectedRecordsChange(records: any[]) {
+ setSelectedRecords(records);
+ }
+
// Update column visibility when hiddenColumns change
- useEffect(() => {
- let cols = dataColumns.map((col) => {
- let hidden: boolean = col.hidden;
+ const dataColumns: any = useMemo(() => {
+ let cols = columns.map((col) => {
+ let hidden: boolean = col.hidden ?? false;
if (col.switchable) {
hidden = hiddenColumns.includes(col.accessor);
@@ -154,14 +160,20 @@ export function InvenTreeTable({
title: '',
hidden: false,
switchable: false,
+ width: 48,
render: function (record: any) {
- return ;
+ return (
+ 0}
+ />
+ );
}
});
}
- setDataColumns(cols);
- }, [columns, hiddenColumns, rowActions]);
+ return cols;
+ }, [columns, hiddenColumns, rowActions, enableSelection, selectedRecords]);
// Callback when column visibility is toggled
function toggleColumn(columnName: string) {
@@ -309,7 +321,7 @@ export function InvenTreeTable({
// Find matching column:
// If column provides custom ordering term, use that
- let column = dataColumns.find((col) => col.accessor == key);
+ let column = dataColumns.find((col: any) => col.accessor == key);
return column?.ordering || key;
}
@@ -317,13 +329,6 @@ export function InvenTreeTable({
const [missingRecordsText, setMissingRecordsText] =
useState(noRecordsText);
- // Data selection
- const [selectedRecords, setSelectedRecords] = useState([]);
-
- function onSelectedRecordsChange(records: any[]) {
- setSelectedRecords(records);
- }
-
const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1);
setSortStatus(status);
@@ -386,6 +391,18 @@ export function InvenTreeTable({
}
);
+ /*
+ * Reload the table whenever the refetch changes
+ * this allows us to programmatically refresh the table
+ *
+ * Implement this using the custom useTableRefresh hook
+ */
+ useEffect(() => {
+ if (refreshId) {
+ refetch();
+ }
+ }, [refreshId]);
+
return (
<>
void;
tooltip?: string;
icon?: ReactNode;
@@ -18,18 +19,22 @@ export type RowAction = {
*/
export function RowActions({
title,
- actions
+ actions,
+ disabled = false
}: {
title?: string;
+ disabled?: boolean;
actions: RowAction[];
}): ReactNode {
return (
actions.length > 0 && (
-