diff --git a/backend/core/admin.py b/backend/core/admin.py index cc2c12e..fe802c5 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -40,6 +40,7 @@ class ProductAdmin(admin.ModelAdmin): "official_product", "pz_code", "created_at", + "updated_at", "status", ) @@ -69,7 +70,7 @@ def has_delete_permission(self, request, obj=None): @admin.register(ProductFile) class ProductFileAdmin(admin.ModelAdmin): list_display = ("id", "product", "file", "role", - "type", "size", "extension") + "type", "size", "extension", "created", "updated") def has_add_permission(self, request): return False diff --git a/backend/core/migrations/0027_product_updated_at.py b/backend/core/migrations/0027_product_updated_at.py new file mode 100644 index 0000000..1d40326 --- /dev/null +++ b/backend/core/migrations/0027_product_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2023-11-09 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_productcontent_alias'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/backend/core/migrations/0028_productfile_created_productfile_updated.py b/backend/core/migrations/0028_productfile_created_productfile_updated.py new file mode 100644 index 0000000..556a4b7 --- /dev/null +++ b/backend/core/migrations/0028_productfile_created_productfile_updated.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.2 on 2023-11-09 13:45 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_product_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='productfile', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='productfile', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/backend/core/models/product.py b/backend/core/models/product.py index abbf891..b576d10 100644 --- a/backend/core/models/product.py +++ b/backend/core/models/product.py @@ -40,6 +40,7 @@ class Product(models.Model): pz_code = models.CharField(max_length=55, null=True, blank=True) description = models.TextField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) status = models.IntegerField( verbose_name="Status", default=ProductStatus.REGISTERING, @@ -70,3 +71,8 @@ def can_delete(self, user) -> bool: if self.user.id == user.id or user.profile.is_admin(): return True return False + + def can_update(self, user) -> bool: + if self.user.id == user.id or user.profile.is_admin(): + return True + return False \ No newline at end of file diff --git a/backend/core/models/product_file.py b/backend/core/models/product_file.py index f8cec2a..a9b61cb 100644 --- a/backend/core/models/product_file.py +++ b/backend/core/models/product_file.py @@ -1,6 +1,7 @@ +import os + from core.models import Product from django.db import models -import os def upload_product_files(instance, filename): @@ -34,6 +35,8 @@ class ProductFile(models.Model): extension = models.CharField( verbose_name="Extension", max_length=10, null=True, blank=True ) + created = models.DateTimeField(auto_now_add=True, blank=True) + updated = models.DateTimeField(auto_now=True) def __str__(self): return f"{self.product.display_name} - {os.path.basename(self.file.name)}" diff --git a/backend/core/serializers/product.py b/backend/core/serializers/product.py index 1c6bdde..2e2e3c6 100644 --- a/backend/core/serializers/product.py +++ b/backend/core/serializers/product.py @@ -22,6 +22,8 @@ class ProductSerializer(serializers.ModelSerializer): can_delete = serializers.SerializerMethodField() + can_update = serializers.SerializerMethodField() + class Meta: model = Product read_only_fields = ("internal_name", "is_owner") @@ -48,4 +50,8 @@ def get_is_owner(self, obj): def get_can_delete(self, obj): current_user = self.context["request"].user - return obj.can_delete(current_user) \ No newline at end of file + return obj.can_delete(current_user) + + def get_can_update(self, obj): + current_user = self.context["request"].user + return obj.can_update(current_user) \ No newline at end of file diff --git a/backend/core/test/test_product.py b/backend/core/test/test_product.py index 8e82eb5..8177516 100644 --- a/backend/core/test/test_product.py +++ b/backend/core/test/test_product.py @@ -289,6 +289,7 @@ def test_product_serialized_format(self): "uploaded_by": self.user.username, "is_owner": True, "can_delete": True, + "can_update": True, "internal_name": self.product.internal_name, "display_name": self.product.display_name, "official_product": self.product.official_product, @@ -296,6 +297,7 @@ def test_product_serialized_format(self): "description": self.product.description, "created_at": self.product.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "status": self.product.status, + "updated_at": self.product.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), } response = self.client.get(self.url) diff --git a/backend/core/test/test_product_file.py b/backend/core/test/test_product_file.py index c3087c8..916f4ae 100644 --- a/backend/core/test/test_product_file.py +++ b/backend/core/test/test_product_file.py @@ -9,7 +9,8 @@ from django.contrib.auth.models import User from django.urls import reverse from rest_framework.authtoken.models import Token -from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) class ProductFileListCreateAPIViewTestCase(APITestCase): @@ -218,6 +219,8 @@ def test_product_file_serialized_format(self): ], "size": self.product_file.file.size, "extension": os.path.splitext(self.product_file.file.name)[1], + "created": self.product_file.created.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "updated": self.product_file.updated.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), } response = self.client.get(self.url) diff --git a/frontend/components/FileUploader.js b/frontend/components/FileUploader.js index 911841a..b3ab978 100644 --- a/frontend/components/FileUploader.js +++ b/frontend/components/FileUploader.js @@ -1,7 +1,7 @@ -import React from 'react' -import { Input, Button } from '@mui/material' +import { Button, Input } from '@mui/material' import prettyBytes from 'pretty-bytes' import PropTypes from 'prop-types' +import React from 'react' export default function FileUploader(props) { const { diff --git a/frontend/components/ProductDetail.js b/frontend/components/ProductDetail.js index 80b4d70..cef95d4 100644 --- a/frontend/components/ProductDetail.js +++ b/frontend/components/ProductDetail.js @@ -2,11 +2,10 @@ import ShareIcon from '@mui/icons-material/Share' import VerifiedIcon from '@mui/icons-material/Verified' import LoadingButton from '@mui/lab/LoadingButton' import { - Tabs, - Tab, Box, Card, CardContent, + CardMedia, Chip, Divider, Grid, @@ -17,13 +16,16 @@ import { Paper, Snackbar, Stack, - Typography, - CardMedia + Tab, + Tabs, + Typography } from '@mui/material' import Alert from '@mui/material/Alert' import ProductShare from './ProductShare' +import EditIcon from '@mui/icons-material/Edit' import moment from 'moment' +import { useRouter } from 'next/router' import prettyBytes from 'pretty-bytes' import PropTypes from 'prop-types' import React from 'react' @@ -37,8 +39,8 @@ import { getProducts } from '../services/product' import useStyles from '../styles/pages/product' - export default function ProductDetail({ productId, internalName }) { + const router = useRouter() const classes = useStyles() const [product, setProduct] = React.useState(null) @@ -170,6 +172,10 @@ export default function ProductDetail({ productId, internalName }) { }) } + const handleEdit = row => { + router.push(`/product/edit/${product.internal_name}`) + } + const createFileFields = file => { // Se o nome do arquivo for grande, // exibe só os primeiros caracteres + extensao. @@ -250,6 +256,11 @@ export default function ProductDetail({ productId, internalName }) { )} + {product.can_update === true && ( + + + + )} {product.official_product === true && ( { + deleteProductFile(id) + .then(() => { + // Forcar um reload dos arquivos + onDelete(id) + }) + .catch(res => { + if (res.response.status === 500) { + // TODO: Tratamento erro no backend + } + }) + } + + const getLabelByRole = role => { + let label = '' + switch (role) { + case 0: + label = 'Main File' + break + case 1: + label = 'Description File' + break + case 2: + label = 'Auxiliary File' + break + } + return label + } + + return ( + + + + + + + ) + } + : {} + } + helperText={prettyBytes(Number(size))} + /> + + ) +} +ProductFileTextField.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + role: PropTypes.number.isRequired, + onDelete: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} +ProductFileTextField.defaultProps = { + readOnly: false +} diff --git a/frontend/components/ProductGrid.js b/frontend/components/ProductGrid.js index c6609c9..3baf70c 100644 --- a/frontend/components/ProductGrid.js +++ b/frontend/components/ProductGrid.js @@ -1,11 +1,12 @@ /* eslint-disable multiline-ternary */ import DeleteIcon from '@mui/icons-material/Delete' import DownloadIcon from '@mui/icons-material/Download' +import EditIcon from '@mui/icons-material/Edit' import ShareIcon from '@mui/icons-material/Share' import Alert from '@mui/material/Alert' -import Tooltip from '@mui/material/Tooltip' import Link from '@mui/material/Link' import Snackbar from '@mui/material/Snackbar' +import Tooltip from '@mui/material/Tooltip' import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid' import moment from 'moment' import { useRouter } from 'next/router' @@ -85,6 +86,10 @@ export default function ProductGrid(props) { setSnackbarOpen(true) } + const handleEdit = row => { + router.push(`/product/edit/${row.internal_name}`) + } + return [ // Hide Id Column ISSUE #123 // { field: 'id', headerName: 'ID', width: 90, sortable: true }, @@ -175,6 +180,29 @@ export default function ProductGrid(props) { ) + }, + { + field: 'can_update', + headerName: 'Edit', + width: 120, + sortable: false, + renderCell: params => ( + +
+ } + onClick={() => handleEdit(params.row)} + disabled={!params.row.can_update} + /> +
+
+ ) } ] }, [getProductUrl, router]) diff --git a/frontend/components/newProduct/Step2.js b/frontend/components/newProduct/Step2.js index 038e44a..ff9020e 100644 --- a/frontend/components/newProduct/Step2.js +++ b/frontend/components/newProduct/Step2.js @@ -1,29 +1,30 @@ -import React, { useState, useEffect } from 'react' +import CloseIcon from '@mui/icons-material/Close' +import UploadIcon from '@mui/icons-material/Upload' import { + Alert, + Box, + Button, + FormGroup, Grid, - Typography, + Stack, TextField, - FormGroup, - Button, - Box, - Alert, - Stack + Typography } from '@mui/material' -import FileUploader from '../FileUploader' -import Loading from '../Loading' -import LinearProgressWithLabel from '../LinearProgressWithLabel' +import IconButton from '@mui/material/IconButton' +import InputAdornment from '@mui/material/InputAdornment' +import prettyBytes from 'pretty-bytes' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import { - getProductFiles, - deleteProductFile, + MAX_UPLOAD_SIZE, createProductFile, + deleteProductFile, + getProductFiles, registryProduct } from '../../services/product' -import InputAdornment from '@mui/material/InputAdornment' -import CloseIcon from '@mui/icons-material/Close' -import UploadIcon from '@mui/icons-material/Upload' -import IconButton from '@mui/material/IconButton' -import prettyBytes from 'pretty-bytes' -import PropTypes from 'prop-types' +import FileUploader from '../FileUploader' +import LinearProgressWithLabel from '../LinearProgressWithLabel' +import Loading from '../Loading' export default function NewProductStep2({ productId, onNext, onPrev }) { const [mainFile, setMainFile] = useState(false) const [mainFileError, setMainFileError] = useState('') @@ -34,7 +35,6 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { const [isLoading, setLoading] = useState(false) const [progress, setProgress] = useState(null) const [formError, setFormError] = React.useState('') - const maxUploadSize = 200 const loadFiles = React.useCallback(async () => { setFormError('') @@ -241,8 +241,8 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { the upload button as many times as necessary). - The maximum upload size is {maxUploadSize}MB. For text files, e.g., CSV, - all commented lines are ignored. Index column is optional. + The maximum upload size is {MAX_UPLOAD_SIZE}MB. For text files, e.g., + CSV, all commented lines are ignored. Index column is optional. For text files, the header is optional (multiline headers are ignored). @@ -267,7 +267,7 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { onFileSelectError={e => { handleFileError(0, e.error) }} - maxSize={maxUploadSize} // 200 MB + maxSize={MAX_UPLOAD_SIZE} // 200 MB buttonProps={{ color: 'primary', disabled: progress !== null, @@ -325,7 +325,7 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { onFileSelectError={e => { handleFileError(2, e.error) }} - maxSize={maxUploadSize} // 200 MB + maxSize={MAX_UPLOAD_SIZE} // 200 MB buttonProps={{ startIcon: , disabled: progress !== null, diff --git a/frontend/pages/product/edit/[pid].js b/frontend/pages/product/edit/[pid].js new file mode 100644 index 0000000..0659575 --- /dev/null +++ b/frontend/pages/product/edit/[pid].js @@ -0,0 +1,378 @@ +import UploadIcon from '@mui/icons-material/Upload' +import VerifiedIcon from '@mui/icons-material/Verified' +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' + +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Card from '@mui/material/Card' +import CardActions from '@mui/material/CardActions' +import CardContent from '@mui/material/CardContent' +import Chip from '@mui/material/Chip' +import Container from '@mui/material/Container' +import FormControl from '@mui/material/FormControl' +import Grid from '@mui/material/Grid' +import Snackbar from '@mui/material/Snackbar' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import moment from 'moment' +import { useRouter } from 'next/router' +import { parseCookies } from 'nookies' +import React, { useState } from 'react' +import FileUploader from '../../../components/FileUploader' +import LinearProgressWithLabel from '../../../components/LinearProgressWithLabel' +import Loading from '../../../components/Loading' +import ProductFileTextField from '../../../components/ProductFileTextField' +import { + MAX_UPLOAD_SIZE, + createProductFile, + getProductByInternalName, + getProductFiles, + patchProduct +} from '../../../services/product' + +export default function EditProduct() { + const router = useRouter() + const { pid } = router.query + + const [isOpen, setIsOpen] = React.useState(false) + const [originalProduct, setOriginalProduct] = React.useState(undefined) + const [product, setProduct] = React.useState(undefined) + const [files, setFiles] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [fileError, setFileError] = React.useState(undefined) + const [progress, setProgress] = useState(null) + + const loadFiles = React.useCallback(async () => { + setIsLoading(true) + getProductFiles(product.id) + .then(res => { + setFiles(res.results) + setIsLoading(false) + }) + .catch(res => { + if (res.response.status === 500) { + // TODO: Tratamento erro no backend + } + setIsLoading(false) + }) + }, [product]) + + const loadProduct = React.useCallback(async () => { + setIsLoading(true) + getProductByInternalName(pid) + .then(res => { + // Apresenta a interface de Produtos + setOriginalProduct(res) + setProduct(res) + setIsLoading(false) + }) + .catch(res => { + // Retorna error 404 + // TODO: Tratar os errors e apresentar. + setIsLoading(false) + }) + }, [pid]) + + React.useEffect(() => { + if (pid) { + loadProduct() + } + }, [pid, loadProduct]) + + React.useEffect(() => { + if (product) { + loadFiles() + } + }, [product, loadFiles]) + + const handleUpdate = () => { + patchProduct(product) + .then(res => { + if (res.status === 200) { + setIsLoading(false) + const data = res.data + setProduct(data) + setOriginalProduct(data) + setIsOpen(true) + } + }) + .catch(res => { + if (res.response.status === 400) { + // Tratamento para erro nos campos + // handleFieldsErrors(res.response.data) + } + if (res.response.status === 500) { + // Tratamento erro no backend + // catchFormError(res.response.data) + } + setIsLoading(false) + }) + } + + const handleOnDeleteFile = fileId => { + loadFiles() + } + + const renderDisplayFile = file => { + return ( + + ) + } + + const onProgress = progressEvent => { + const progress = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ) + setProgress(progress) + } + + const checkFileName = (name, files) => { + let isOk = true + // Verifica se já existe um arquivo com mesmo nome. + files.forEach(f => { + if (f.name === name) { + isOk = false + } + }) + return isOk + } + + const handleUploadFile = file => { + // Os arquivos uploaded nesta página sempre serão auxiliares + const role = 2 + + if (!checkFileName(file.name, files)) { + setFileError('A file with the same name already exists') + return + } + + createProductFile(product.id, file, role, onProgress) + .then(res => { + if (res.status === 201) { + setProgress(null) + loadFiles() + } + }) + .catch(res => { + if (res.response.status === 400) { + // Tratamento erro no backend regra de negocio dos arquivos enviados + if ('file' in res.response.data) { + setFileError(res.response.data.file[0]) + } else { + setFileError(res.response.data.error) + } + } + if (res.response.status === 500) { + // catchFormError(res.response.data) + setFileError(res.response.data.error) + } + setProgress(null) + loadFiles() + }) + } + + return ( + + {isLoading && } + + + + { + router.back() + }} + sx={{ cursor: 'pointer' }} + /> + Edit Product + + + + {product !== undefined && ( + + + + + + + {product.display_name} + + {product.official_product === true && ( + } + /> + )} + + + + Created at:{' '} + {moment(product.created_at).format('L LTS')} + + + Uploaded by: {product.uploaded_by} + + + + Last update: {moment(product?.updated_at).format('L LTS')} + + + + {product.release_name} - {product.product_type_name} + + + + + setProduct(prev => { + return { + ...prev, + description: e.target.value + } + }) + } + /> + + + + + + + + + + + + + {files.map(row => { + return renderDisplayFile(row) + })} + + {progress && } + + { + handleUploadFile(file) + }} + onFileSelectError={e => { + setFileError(e.error) + }} + maxSize={MAX_UPLOAD_SIZE} // 200 MB + buttonProps={{ + startIcon: , + disabled: progress !== null, + fullWidth: true + }} + /> + {fileError !== undefined && ( + { + setFileError(undefined) + }} + > + {fileError} + + )} + + + + + + )} + + setIsOpen(false)} + message="Product has been updated" + /> + + + ) +} + +export const getServerSideProps = async ctx => { + const { 'pzserver.access_token': token } = parseCookies(ctx) + + // A better way to validate this is to have + // an endpoint to verify the validity of the token: + if (!token) { + return { + redirect: { + destination: '/login', + permanent: false + } + } + } + + return { + props: {} + } +} diff --git a/frontend/services/product.js b/frontend/services/product.js index 89f52ff..9bd2cab 100644 --- a/frontend/services/product.js +++ b/frontend/services/product.js @@ -18,6 +18,8 @@ export const downloadProduct = (id, internalName) => { }) } +export const MAX_UPLOAD_SIZE = 200 + // Exemplo de como enviar arquivo via upload: https://dev.to/thomz/uploading-images-to-django-rest-framework-from-forms-in-react-3jhj export const createProduct = data => { const formData = new FormData() @@ -137,6 +139,17 @@ export const getProduct = product_id => { return api.get(`/api/products/${product_id}/`).then(res => res.data) } +export const getProductByInternalName = (internalName) => { + return api.get(`/api/products/`, { params: { internal_name: internalName } }).then((res) => { + if (res.data.count == 1) { + return res.data.results[0] + } else { + return undefined + } + }); + +} + export const fetchProductData = ({ queryKey }) => { const [_, params] = queryKey const { productId, page, pageSize: page_size } = params