mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55:44 +00:00
Single table for file attachments (#7420)
* Add basic model for handling generic attachments * Refactor migration * Data migration to convert old files across * Admin updates * Increase comment field max_length * Adjust field name * Remove legacy serializer classes / endpoints * Expose new model to API * Admin site list filters * Remove legacy attachment models - Add new mixin class to designate which models can have attachments * Update data migration - Ensure other apps are at the correct migration state beforehand * Add migrations to remove legacy attachment tables * Fix for "rename_attachment" callback * Refactor model_type field - ContentType does not allow easy API serialization * Set allowed options for admin * Update model verbose names * Fix logic for file upload * Add choices for serializer * Add API filtering * Fix for API filter * Fix for attachment tables in PUI - Still not solved permission issues * Bump API version * Record user when uploading attachment via API * Refactor <AttachmentTable /> for PUI * Display 'file_size' in PUI attachment table * Fix company migrations * Include permission informtion in roles API endpoint * Read user permissions in PUI * Simplify permission checks for <AttachmentTable /> * Automatically clean up old content types * Cleanup PUI * Fix typo in data migration * Add reverse data migration * Update unit tests * Use InMemoryStorage for media files in test mode * Data migration unit test * Fix "model_type" field - It is a required field after all * Add permission check for serializer * Fix permission check for CUI * Fix PUI import * Test python lib against specific branch - Will be reverted once code is merged * Revert STORAGES setting - Might be worth looking into again * Fix part unit test * Fix unit test for sales order * Use 'get_global_setting' * Use 'get_global_setting' * Update setting getter * Unit tests * Tweaks * Revert change to settings.py * More updates for get_global_setting * Relax API query count requirement * remove illegal chars and add unit tests * Fix unit tests * Fix frontend unit tests * settings management updates * Prevent db write under more conditions * Simplify settings code * Pop values before creating filters * Prevent settings write under certain conditions * Add debug msg * Clear db on record import * Refactor permissions checks - Allows extension / customization of permission checks at a later date * Unit test updates * Prevent delete of attachment without correct permissions * Adjust odcker.yaml * Cleanup data migrations * Tweak migration tests for build app * Update data migration - Handle case with missing data * Prevent debug shell in TESTING mode * Update migration dependencies - Ensure all apps are "up to date" before removing legacy tables * add file size test * Update migration tests * Revert some settings caching changes * Fix incorrect logic in migration * Update unit tests * Prevent create on CURRENCY_CODES - Seems to play havoc with bootup sequence * Fix unit test * Some refactoring - Use get_global_setting * Fix typo * Revert change * Add "tags" and "metadata" * Include "tags" field in API serializer * add "metadata" endpoint for attachments
This commit is contained in:
@ -117,7 +117,23 @@ export function formatPriceRange(
|
||||
)}`;
|
||||
}
|
||||
|
||||
interface RenderDateOptionsInterface {
|
||||
/*
|
||||
* Format a file size (in bytes) into a human-readable format
|
||||
*/
|
||||
export function formatFileSize(size: number) {
|
||||
const suffixes: string[] = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let idx = 0;
|
||||
|
||||
while (size > 1024 && idx < suffixes.length) {
|
||||
size /= 1024;
|
||||
idx++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${suffixes[idx]}`;
|
||||
}
|
||||
|
||||
interface FormatDateOptionsInterface {
|
||||
showTime?: boolean;
|
||||
showSeconds?: boolean;
|
||||
}
|
||||
@ -128,9 +144,9 @@ interface RenderDateOptionsInterface {
|
||||
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
|
||||
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
|
||||
*/
|
||||
export function renderDate(
|
||||
export function formatDate(
|
||||
date: string,
|
||||
options: RenderDateOptionsInterface = {}
|
||||
options: FormatDateOptionsInterface = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return '-';
|
||||
|
@ -57,7 +57,6 @@ export enum ApiEndpoints {
|
||||
build_output_complete = 'build/:id/complete/',
|
||||
build_output_scrap = 'build/:id/scrap-outputs/',
|
||||
build_output_delete = 'build/:id/delete-outputs/',
|
||||
build_order_attachment_list = 'build/attachment/',
|
||||
build_line_list = 'build/line/',
|
||||
|
||||
bom_list = 'bom/',
|
||||
@ -76,18 +75,15 @@ export enum ApiEndpoints {
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
related_part_list = 'part/related/',
|
||||
part_attachment_list = 'part/attachment/',
|
||||
part_test_template_list = 'part/test-template/',
|
||||
|
||||
// Company API endpoints
|
||||
company_list = 'company/',
|
||||
contact_list = 'company/contact/',
|
||||
address_list = 'company/address/',
|
||||
company_attachment_list = 'company/attachment/',
|
||||
supplier_part_list = 'company/part/',
|
||||
supplier_part_pricing_list = 'company/price-break/',
|
||||
manufacturer_part_list = 'company/part/manufacturer/',
|
||||
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
|
||||
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
||||
|
||||
// Stock API endpoints
|
||||
@ -96,7 +92,6 @@ export enum ApiEndpoints {
|
||||
stock_location_list = 'stock/location/',
|
||||
stock_location_type_list = 'stock/location-type/',
|
||||
stock_location_tree = 'stock/location/tree/',
|
||||
stock_attachment_list = 'stock/attachment/',
|
||||
stock_test_result_list = 'stock/test/',
|
||||
stock_transfer = 'stock/transfer/',
|
||||
stock_remove = 'stock/remove/',
|
||||
@ -115,16 +110,13 @@ export enum ApiEndpoints {
|
||||
// Order API endpoints
|
||||
purchase_order_list = 'order/po/',
|
||||
purchase_order_line_list = 'order/po-line/',
|
||||
purchase_order_attachment_list = 'order/po/attachment/',
|
||||
purchase_order_receive = 'order/po/:id/receive/',
|
||||
|
||||
sales_order_list = 'order/so/',
|
||||
sales_order_line_list = 'order/so-line/',
|
||||
sales_order_attachment_list = 'order/so/attachment/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_attachment_list = 'order/ro/attachment/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
@ -155,6 +147,7 @@ export enum ApiEndpoints {
|
||||
machine_setting_detail = 'machine/:machine/settings/:config_type/',
|
||||
|
||||
// Miscellaneous API endpoints
|
||||
attachment_list = 'attachment/',
|
||||
error_report_list = 'error-report/',
|
||||
project_code_list = 'project-code/',
|
||||
custom_unit_list = 'units/',
|
||||
|
@ -56,20 +56,6 @@ function ApiFormsPlayground() {
|
||||
fields: editPartFields
|
||||
});
|
||||
|
||||
const newAttachment = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_attachment_list,
|
||||
title: 'Create Attachment',
|
||||
fields: {
|
||||
part: {},
|
||||
attachment: {},
|
||||
comment: {}
|
||||
},
|
||||
initialData: {
|
||||
part: 1
|
||||
},
|
||||
successMessage: 'Attachment uploaded'
|
||||
});
|
||||
|
||||
const [active, setActive] = useState(true);
|
||||
const [name, setName] = useState('Hello');
|
||||
|
||||
@ -130,9 +116,6 @@ function ApiFormsPlayground() {
|
||||
<Button onClick={() => editCategory.open()}>Edit Category</Button>
|
||||
{editCategory.modal}
|
||||
|
||||
<Button onClick={() => newAttachment.open()}>Create Attachment</Button>
|
||||
{newAttachment.modal}
|
||||
|
||||
<Button onClick={() => openCreatePart()}>Create Part new Modal</Button>
|
||||
{createPartModal}
|
||||
</Group>
|
||||
|
@ -295,11 +295,7 @@ export default function BuildDetail() {
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.build_order_attachment_list}
|
||||
model="build"
|
||||
pk={Number(id)}
|
||||
/>
|
||||
<AttachmentTable model_type={ModelType.build} model_id={Number(id)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -256,9 +256,8 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.company_attachment_list}
|
||||
model="company"
|
||||
pk={company.pk ?? -1}
|
||||
model_type={ModelType.company}
|
||||
model_id={company.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -173,9 +173,8 @@ export default function ManufacturerPartDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.manufacturer_part_attachment_list}
|
||||
model="manufacturer_part"
|
||||
pk={manufacturerPart?.pk}
|
||||
model_type={ModelType.manufacturerpart}
|
||||
model_id={manufacturerPart?.pk}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -618,11 +618,7 @@ export default function PartDetail() {
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.part_attachment_list}
|
||||
model="part"
|
||||
pk={part.pk ?? -1}
|
||||
/>
|
||||
<AttachmentTable model_type={ModelType.part} model_id={part?.pk} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
@ -22,7 +22,7 @@ import { DataTable } from 'mantine-datatable';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { formatCurrency, formatDate } from '../../../defaults/formatters';
|
||||
import { panelOptions } from '../PartPricingPanel';
|
||||
|
||||
interface PricingOverviewEntry {
|
||||
@ -173,7 +173,7 @@ export default function PricingOverviewPanel({
|
||||
{pricing?.updated && (
|
||||
<Paper p="xs">
|
||||
<Alert color="blue" title={t`Last Updated`}>
|
||||
<Text>{renderDate(pricing.updated)}</Text>
|
||||
<Text>{formatDate(pricing.updated)}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
@ -3,7 +3,7 @@ import { BarChart } from '@mantine/charts';
|
||||
import { Group, SimpleGrid, Text } from '@mantine/core';
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { formatCurrency, formatDate } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
@ -40,7 +40,7 @@ export default function PurchaseHistoryPanel({
|
||||
title: t`Date`,
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => renderDate(record.order_detail.complete_date)
|
||||
render: (record: any) => formatDate(record.order_detail.complete_date)
|
||||
},
|
||||
{
|
||||
accessor: 'purchase_price',
|
||||
|
@ -279,9 +279,8 @@ export default function PurchaseOrderDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.purchase_order_attachment_list}
|
||||
model="order"
|
||||
pk={Number(id)}
|
||||
model_type={ModelType.purchaseorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -230,9 +230,8 @@ export default function ReturnOrderDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.return_order_attachment_list}
|
||||
model="order"
|
||||
pk={Number(id)}
|
||||
model_type={ModelType.returnorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -280,9 +280,8 @@ export default function SalesOrderDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.sales_order_attachment_list}
|
||||
model="order"
|
||||
pk={Number(id)}
|
||||
model_type={ModelType.salesorder}
|
||||
model_id={order.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -329,9 +329,8 @@ export default function StockDetail() {
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiEndpoints.stock_attachment_list}
|
||||
model="stock_item"
|
||||
pk={Number(id)}
|
||||
model_type={ModelType.stockitem}
|
||||
model_id={stockitem.pk}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
|
||||
import { api, setApiDefaults } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { UserPermissions, UserRoles } from '../enums/Roles';
|
||||
import { clearCsrfCookie } from '../functions/auth';
|
||||
import { apiUrl } from './ApiState';
|
||||
@ -22,6 +23,14 @@ interface UserStateProps {
|
||||
hasChangeRole: (role: UserRoles) => boolean;
|
||||
hasAddRole: (role: UserRoles) => boolean;
|
||||
hasViewRole: (role: UserRoles) => boolean;
|
||||
checkUserPermission: (
|
||||
model: ModelType,
|
||||
permission: UserPermissions
|
||||
) => boolean;
|
||||
hasDeletePermission: (model: ModelType) => boolean;
|
||||
hasChangePermission: (model: ModelType) => boolean;
|
||||
hasAddPermission: (model: ModelType) => boolean;
|
||||
hasViewPermission: (model: ModelType) => boolean;
|
||||
isLoggedIn: () => boolean;
|
||||
isStaff: () => boolean;
|
||||
isSuperuser: () => boolean;
|
||||
@ -113,6 +122,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
||||
// Update user with role data
|
||||
if (user) {
|
||||
user.roles = response.data?.roles ?? {};
|
||||
user.permissions = response.data?.permissions ?? {};
|
||||
user.is_staff = response.data?.is_staff ?? false;
|
||||
user.is_superuser = response.data?.is_superuser ?? false;
|
||||
set({ user: user });
|
||||
@ -126,21 +136,6 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
||||
get().clearUserState();
|
||||
});
|
||||
},
|
||||
checkUserRole: (role: UserRoles, permission: UserPermissions) => {
|
||||
// Check if the user has the specified permission for the specified role
|
||||
const user: UserProps = get().user as UserProps;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user?.is_superuser) return true;
|
||||
if (user?.roles === undefined) return false;
|
||||
if (user?.roles[role] === undefined) return false;
|
||||
if (user?.roles[role] === null) return false;
|
||||
|
||||
return user?.roles[role]?.includes(permission) ?? false;
|
||||
},
|
||||
isLoggedIn: () => {
|
||||
if (!get().token) {
|
||||
return false;
|
||||
@ -156,6 +151,21 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
||||
const user: UserProps = get().user as UserProps;
|
||||
return user?.is_superuser ?? false;
|
||||
},
|
||||
checkUserRole: (role: UserRoles, permission: UserPermissions) => {
|
||||
// Check if the user has the specified permission for the specified role
|
||||
const user: UserProps = get().user as UserProps;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user?.is_superuser) return true;
|
||||
if (user?.roles === undefined) return false;
|
||||
if (user?.roles[role] === undefined) return false;
|
||||
if (user?.roles[role] === null) return false;
|
||||
|
||||
return user?.roles[role]?.includes(permission) ?? false;
|
||||
},
|
||||
hasDeleteRole: (role: UserRoles) => {
|
||||
return get().checkUserRole(role, UserPermissions.delete);
|
||||
},
|
||||
@ -167,5 +177,33 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
||||
},
|
||||
hasViewRole: (role: UserRoles) => {
|
||||
return get().checkUserRole(role, UserPermissions.view);
|
||||
},
|
||||
checkUserPermission: (model: ModelType, permission: UserPermissions) => {
|
||||
// Check if the user has the specified permission for the specified model
|
||||
const user: UserProps = get().user as UserProps;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user?.is_superuser) return true;
|
||||
|
||||
if (user?.permissions === undefined) return false;
|
||||
if (user?.permissions[model] === undefined) return false;
|
||||
if (user?.permissions[model] === null) return false;
|
||||
|
||||
return user?.permissions[model]?.includes(permission) ?? false;
|
||||
},
|
||||
hasDeletePermission: (model: ModelType) => {
|
||||
return get().checkUserPermission(model, UserPermissions.delete);
|
||||
},
|
||||
hasChangePermission: (model: ModelType) => {
|
||||
return get().checkUserPermission(model, UserPermissions.change);
|
||||
},
|
||||
hasAddPermission: (model: ModelType) => {
|
||||
return get().checkUserPermission(model, UserPermissions.add);
|
||||
},
|
||||
hasViewPermission: (model: ModelType) => {
|
||||
return get().checkUserPermission(model, UserPermissions.view);
|
||||
}
|
||||
}));
|
||||
|
@ -23,6 +23,7 @@ export interface UserProps {
|
||||
is_staff?: boolean;
|
||||
is_superuser?: boolean;
|
||||
roles?: Record<string, string[]>;
|
||||
permissions?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// Type interface fully defining the current server
|
||||
|
@ -9,7 +9,7 @@ import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { ProgressBar } from '../components/items/ProgressBar';
|
||||
import { TableStatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderOwner } from '../components/render/User';
|
||||
import { formatCurrency, renderDate } from '../defaults/formatters';
|
||||
import { formatCurrency, formatDate } from '../defaults/formatters';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { resolveItem } from '../functions/conversion';
|
||||
import { cancelEvent } from '../functions/events';
|
||||
@ -180,7 +180,7 @@ export function DateColumn(props: TableColumnProps): TableColumn {
|
||||
title: t`Date`,
|
||||
switchable: true,
|
||||
render: (record: any) =>
|
||||
renderDate(resolveItem(record, props.accessor ?? 'date')),
|
||||
formatDate(resolveItem(record, props.accessor ?? 'date')),
|
||||
...props
|
||||
};
|
||||
}
|
||||
|
@ -14,7 +14,9 @@ import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { formatFileSize } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
@ -22,7 +24,9 @@ import {
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { TableColumn } from '../Column';
|
||||
import { TableFilter } from '../Filter';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||
|
||||
@ -36,7 +40,7 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
noWrap: true,
|
||||
render: function (record: any) {
|
||||
render: (record: any) => {
|
||||
if (record.attachment) {
|
||||
return <AttachmentLink attachment={record.attachment} />;
|
||||
} else if (record.link) {
|
||||
@ -50,7 +54,7 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
accessor: 'comment',
|
||||
sortable: false,
|
||||
|
||||
render: function (record: any) {
|
||||
render: (record: any) => {
|
||||
return record.comment;
|
||||
}
|
||||
},
|
||||
@ -58,7 +62,7 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
accessor: 'upload_date',
|
||||
sortable: true,
|
||||
|
||||
render: function (record: any) {
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Text>{record.upload_date}</Text>
|
||||
@ -68,6 +72,18 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'file_size',
|
||||
sortable: true,
|
||||
switchable: true,
|
||||
render: (record: any) => {
|
||||
if (!record.attachment) {
|
||||
return '-';
|
||||
} else {
|
||||
return formatFileSize(record.file_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@ -76,50 +92,34 @@ function attachmentTableColumns(): TableColumn[] {
|
||||
* Construct a table for displaying uploaded attachments
|
||||
*/
|
||||
export function AttachmentTable({
|
||||
endpoint,
|
||||
model,
|
||||
pk
|
||||
model_type,
|
||||
model_id
|
||||
}: {
|
||||
endpoint: ApiEndpoints;
|
||||
pk: number;
|
||||
model: string;
|
||||
model_type: ModelType;
|
||||
model_id: number;
|
||||
}): ReactNode {
|
||||
const table = useTable(`${model}-attachments`);
|
||||
const user = useUserState();
|
||||
const table = useTable(`${model_type}-attachments`);
|
||||
|
||||
const tableColumns = useMemo(() => attachmentTableColumns(), []);
|
||||
|
||||
const [allowEdit, setAllowEdit] = useState<boolean>(false);
|
||||
const [allowDelete, setAllowDelete] = useState<boolean>(false);
|
||||
const url = apiUrl(ApiEndpoints.attachment_list);
|
||||
|
||||
const url = useMemo(() => apiUrl(endpoint), [endpoint]);
|
||||
|
||||
const validPk = useMemo(() => pk > 0, [pk]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
}, [url]);
|
||||
const validPk = useMemo(() => model_id > 0, [model_id]);
|
||||
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||
|
||||
const allowDragAndDrop: boolean = useMemo(() => {
|
||||
return user.hasAddPermission(model_type);
|
||||
}, [user, model_type]);
|
||||
|
||||
// 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());
|
||||
formData.append('model_type', model_type);
|
||||
formData.append('model_id', model_id.toString());
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
@ -161,8 +161,12 @@ export function AttachmentTable({
|
||||
|
||||
const uploadFields: ApiFormFieldSet = useMemo(() => {
|
||||
let fields: ApiFormFieldSet = {
|
||||
[model]: {
|
||||
value: pk,
|
||||
model_type: {
|
||||
value: model_type,
|
||||
hidden: true
|
||||
},
|
||||
model_id: {
|
||||
value: model_id,
|
||||
hidden: true
|
||||
},
|
||||
attachment: {},
|
||||
@ -180,10 +184,10 @@ export function AttachmentTable({
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [endpoint, model, pk, attachmentType, selectedAttachment]);
|
||||
}, [model_type, model_id, attachmentType, selectedAttachment]);
|
||||
|
||||
const uploadAttachment = useCreateApiFormModal({
|
||||
url: endpoint,
|
||||
url: url,
|
||||
title: t`Upload Attachment`,
|
||||
fields: uploadFields,
|
||||
onFormSuccess: () => {
|
||||
@ -192,7 +196,7 @@ export function AttachmentTable({
|
||||
});
|
||||
|
||||
const editAttachment = useEditApiFormModal({
|
||||
url: endpoint,
|
||||
url: url,
|
||||
pk: selectedAttachment,
|
||||
title: t`Edit Attachment`,
|
||||
fields: uploadFields,
|
||||
@ -206,7 +210,7 @@ export function AttachmentTable({
|
||||
});
|
||||
|
||||
const deleteAttachment = useDeleteApiFormModal({
|
||||
url: endpoint,
|
||||
url: url,
|
||||
pk: selectedAttachment,
|
||||
title: t`Delete Attachment`,
|
||||
onFormSuccess: () => {
|
||||
@ -214,12 +218,27 @@ export function AttachmentTable({
|
||||
}
|
||||
});
|
||||
|
||||
const tableFilters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'is_link',
|
||||
label: t`Is Link`,
|
||||
description: t`Show link attachments`
|
||||
},
|
||||
{
|
||||
name: 'is_file',
|
||||
label: t`Is File`,
|
||||
description: t`Show file attachments`
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions: ReactNode[] = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
key="add-attachment"
|
||||
tooltip={t`Add attachment`}
|
||||
hidden={!allowEdit}
|
||||
hidden={!user.hasAddPermission(model_type)}
|
||||
icon={<IconFileUpload />}
|
||||
onClick={() => {
|
||||
setAttachmentType('attachment');
|
||||
@ -230,7 +249,7 @@ export function AttachmentTable({
|
||||
<ActionButton
|
||||
key="add-external-link"
|
||||
tooltip={t`Add external link`}
|
||||
hidden={!allowEdit}
|
||||
hidden={!user.hasAddPermission(model_type)}
|
||||
icon={<IconExternalLink />}
|
||||
onClick={() => {
|
||||
setAttachmentType('link');
|
||||
@ -239,38 +258,29 @@ export function AttachmentTable({
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [allowEdit]);
|
||||
}, [user, model_type]);
|
||||
|
||||
// Construct row actions for the attachment table
|
||||
const rowActions = useCallback(
|
||||
(record: any) => {
|
||||
let actions: RowAction[] = [];
|
||||
|
||||
if (allowEdit) {
|
||||
actions.push(
|
||||
RowEditAction({
|
||||
onClick: () => {
|
||||
setSelectedAttachment(record.pk);
|
||||
editAttachment.open();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (allowDelete) {
|
||||
actions.push(
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
setSelectedAttachment(record.pk);
|
||||
deleteAttachment.open();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangePermission(model_type),
|
||||
onClick: () => {
|
||||
setSelectedAttachment(record.pk);
|
||||
editAttachment.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeletePermission(model_type),
|
||||
onClick: () => {
|
||||
setSelectedAttachment(record.pk);
|
||||
deleteAttachment.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[allowEdit, allowDelete]
|
||||
[user, model_type]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -279,7 +289,7 @@ export function AttachmentTable({
|
||||
{editAttachment.modal}
|
||||
{deleteAttachment.modal}
|
||||
<Stack gap="xs">
|
||||
{pk && pk > 0 && (
|
||||
{validPk && (
|
||||
<InvenTreeTable
|
||||
key="attachment-table"
|
||||
url={url}
|
||||
@ -289,14 +299,16 @@ export function AttachmentTable({
|
||||
noRecordsText: t`No attachments found`,
|
||||
enableSelection: true,
|
||||
tableActions: tableActions,
|
||||
rowActions: allowEdit && allowDelete ? rowActions : undefined,
|
||||
tableFilters: tableFilters,
|
||||
rowActions: rowActions,
|
||||
params: {
|
||||
[model]: pk
|
||||
model_type: model_type,
|
||||
model_id: model_id
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowEdit && validPk && (
|
||||
{allowDragAndDrop && validPk && (
|
||||
<Paper p="md" shadow="xs" radius="md">
|
||||
<Dropzone
|
||||
onDrop={uploadFiles}
|
||||
|
@ -16,7 +16,7 @@ import { PassFailButton } from '../../components/buttons/YesNoButton';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import { renderDate } from '../../defaults/formatters';
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { useTestResultFields } from '../../forms/StockForms';
|
||||
@ -207,7 +207,7 @@ export default function StockItemTestResultTable({
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
{renderDate(record.started_datetime, {
|
||||
{formatDate(record.started_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
@ -222,7 +222,7 @@ export default function StockItemTestResultTable({
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
{renderDate(record.finished_datetime, {
|
||||
{formatDate(record.finished_datetime, {
|
||||
showTime: true,
|
||||
showSeconds: true
|
||||
})}
|
||||
|
@ -56,12 +56,6 @@ test('PUI - Pages - Index - Playground', async ({ page }) => {
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
|
||||
// Create Attachment
|
||||
await page.getByRole('button', { name: 'Create Attachment' }).click();
|
||||
await page.getByLabel('Attachment *').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
// TODO: actually create an attachment
|
||||
|
||||
// Create Part new Modal
|
||||
await page.getByRole('button', { name: 'Create Part new Modal' }).click();
|
||||
await page.locator('.css-fehojk-Input2').first().click();
|
||||
|
Reference in New Issue
Block a user