diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js index 33362648..1fe11a20 100644 --- a/assets/controllers/elements/datatables/parts_controller.js +++ b/assets/controllers/elements/datatables/parts_controller.js @@ -107,6 +107,13 @@ export default class extends DatatablesController { //Hide the select element (the tomselect button is the sibling of the select element) select_target.nextElementSibling.classList.add('d-none'); } + + //If the selected option has a data-turbo attribute, set it to the form + if (selected_option.dataset.turbo) { + this.element.dataset.turbo = selected_option.dataset.turbo; + } else { + this.element.dataset.turbo = true; + } } confirmDeletionAtSubmit(event) { diff --git a/assets/js/error_handler.js b/assets/js/error_handler.js index f4659269..2b2e35e8 100644 --- a/assets/js/error_handler.js +++ b/assets/js/error_handler.js @@ -28,7 +28,9 @@ class ErrorHandlerHelper { console.log('Error Handler registered'); const content = document.getElementById('content'); - content.addEventListener('turbo:before-fetch-response', (event) => this.handleError(event)); + //content.addEventListener('turbo:before-fetch-response', (event) => this.handleError(event)); + content.addEventListener('turbo:fetch-request-error', (event) => this.handleError(event)); + content.addEventListener('turbo:frame-missing', (event) => this.handleError(event)); $(document).ajaxError(this.handleJqueryErrror.bind(this)); } @@ -87,8 +89,10 @@ class ErrorHandlerHelper { } handleError(event) { - const fetchResponse = event.detail.fetchResponse; - const response = fetchResponse.response; + //Prevent default error handling + event.preventDefault(); + + const response = event.detail.response; //Ignore aborted requests. if (response.statusText === 'abort' || response.status == 0) { @@ -100,11 +104,11 @@ class ErrorHandlerHelper { return; } - if(fetchResponse.failed) { + if(!response.ok) { response.text().then(responseHTML => { - this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), responseHTML); + this._showAlert(response.statusText, response.status, response.url, responseHTML); }).catch(err => { - this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), '
' + err + '
'); + this._showAlert(response.statusText, response.status, response.url, '
' + err + '
'); }); } } diff --git a/config/permissions.yaml b/config/permissions.yaml index f9b4a1ee..bcd3d79c 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -43,6 +43,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co revert_element: label: "perm.revert_elements" alsoSet: ["read", "edit", "create", "delete", "show_history"] + import: + label: "perm.import" + alsoSet: ["read", "edit", "create"] parts_stock: group: "data" @@ -76,6 +79,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co revert_element: label: "perm.revert_elements" alsoSet: ["read", "edit", "create", "delete", "show_history"] + import: + label: "perm.import" + alsoSet: [ "read", "edit", "create" ] footprints: <<: *PART_CONTAINING @@ -156,6 +162,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co revert_element: label: "perm.revert_elements" alsoSet: ["read", "edit", "create", "delete", "edit_permissions", "show_history"] + import: + label: "perm.import" + alsoSet: [ "read", "edit", "create" ] users: label: "perm.users" @@ -188,6 +197,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co revert_element: label: "perm.revert_elements" alsoSet: ["read", "create", "delete", "edit_permissions", "show_history", "edit_infos", "edit_username"] + import: + label: "perm.import" + alsoSet: [ "read", "create" ] #database: # label: "perm.database" diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv new file mode 100644 index 00000000..08701426 --- /dev/null +++ b/docs/assets/usage/import_export/part_import_example.csv @@ -0,0 +1,4 @@ +name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status +BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;; +BC557;PNP transistor;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active +Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter; \ No newline at end of file diff --git a/docs/usage/import_export.md b/docs/usage/import_export.md new file mode 100644 index 00000000..c16f5767 --- /dev/null +++ b/docs/usage/import_export.md @@ -0,0 +1,101 @@ +--- +layout: default +title: Import & Export data +nav_order: 4 +parent: Usage +--- + +# Import & Export data + +Part-DB offers the possibility to import existing data (parts, datastructures, etc.) from existing datasources into Part-DB. Data can also be exported from Part-DB into various formats. + +## Import + +{: .note } +> As data import is a very powerful feature and can easily fill up your database with lots of data, import is by default only available for +> administrators. If you want to allow other users to import data, or can not import data, check the permissions of the user. You can enable import for each data structure +> individually in the permissions settings. + +### Import parts + +Part-DB supports the import of parts from CSV files and other formats. This can be used to import existing parts from other databases or datasources into Part-DB. The import can be done via the "Tools -> Import parts" page, which you can find in the "Tools" sidebar panel. + +{: .important } +> When importing data, the data is immediatley written to database during the import process, when the data is formally valid. +> You will not be able to check the data before it is written to the database, so you should review the data before using the import tool. + +You can upload the file which should be imported here and choose various options on how the data should be treated: +* **Format**: By default "auto" is selected here and Part-DB will try to detect the format of the file automatically based on its file extension. If you want to force a specific format or Part-DB can not auto-detect the format, you can select it here. +* **CSV delimiter**: If you upload an CSV file, you can select the delimiter character which is used to separate the columns in the CSV file. Depending on the CSV file, this might be a comma (`,`), semicolon (`;`). +* **Category override**: You can select (or create) a category here, to which all imported parts should be assigned, no matter what was specified in the import file. This can be useful if you want to assign all imports to a certain category or if no category is specified in the data. If you leave this field empty, the category will be determined by the import file (or the export will error, if no category is specified). +* **Mark all imported parts as "Needs review"**: If this is selected, all imported parts will be marked as "Needs review" after the import. This can be useful if you want to review all imported parts before using them. +* **Create unknown datastructures**: If this is selected Part-DB will create new datastructures (like categories, manufacturers, etc.) if no datastructure(s) with the same name and path already exists. If this is not selected, only existing datastructures will be used and if no matching datastrucure is found, the imported parts field will be empty. +* **Path delimiter**: Part-DB allows you to create/select nested datastructures (like categories, manufacturers, etc.) by using a path (e.g. `Category 1->Category 1.1`, which will select/create the `Category 1.1` whose parent is `Category 1`). This path is separated by the path delimiter. If you want to use a different path delimiter than the default one (which is `>`), you can select it here. +* **Abort on validation error**: If this is selected, the import will be aborted if a validation error occurs (e.g. if a required field is empty) for any of the imported parts and validation errors will be shown on top of the page. If this is not selected, the import will continue for the other parts and only the invalid parts will be skipped. + +After you have selected the options, you can start the import by clicking the "Import" button. When the import is finished, you will see the results of the import in the lower half of the page. You find a table with the imported parts (including links to them) there. + +#### Fields description + +For the importing of parts, you can use the following fields which will be imported into each part. Please note that the field names are case sensitive (so `name` is not the same as `Name`). All fields (besides name) are optional, so you can leave them empty or do not include the column in your file. + +* **`name`** (required): The name of the part. This is the only required field, all other fields are optional. +* **`description`**: The description of the part, you can use markdown/HTML syntax here for rich text formatting. +* **`notes`** or **`comment`**: The notes of the part, you can use markdown/HTML syntax here for rich text formatting. +* **`category`**: The category of the part. This can be a path (e.g. `Category 1->Category 1.1`), which will select/create the `Category 1.1` whose parent is `Category 1`. If you want to use a different path delimiter than the default one (which is `->`), you can select it in the import options. If the category does not exist and the option "Create unknown datastructures" is selected, it will be created. +* **`footprint`**: The footprint of the part. Can be a path similar to the category field. +* **`favorite`**: If this is set to `1`, the part will be marked as favorite. +* **`manufacturer`**: The manufacturer of the part. Can be a path similar to the category field. +* **`manufacturer_product_number`** or **`mpn`**: The manufacturer product number of the part. +* **`manufacturer_product_url`: The URL to the product page of the manufacturer of the part. +* **`manufacturing_status`**: The manufacturing status of the part, must be one of the following values: `announced`, `active`, `nrfnd`, `eol`, `discontinued` or left empty. +* **`needs_review`** or **`needs_review`**: If this is set to `1`, the part will be marked as "needs review". +* **`tags`**: A comma separated list of tags for the part. +* **`mass`**: The mass of the part in grams. +* **`ipn`**: The IPN (Item Part Number) of the part. +* **`minamount`**: The minimum amount of the part which should be in stock. +* **`partUnit`**: The measurement unit of the part to use. Can be a path similar to the category field. + +With the following fields you can specify storage locations and amount / quantiy in stock of the part. An PartLot will be created automatically from the data and assigned to the part. The following fields are helpers for an easy import for parts at one storage location. If you need to create a Part with multiple PartLots you have to use JSON format (or CSV) with nested objects: + +**`storage_location`** or **`storelocation`**: The storage location of the part. Can be a path similar to the category field. +**`amount`**, **`quantity`** or **`instock`**: The amount of the part in stock. If this value is not set, the part lot will be marked with "unknown amount" + +The following fields can be used to specify the supplier/distributor, supplier product number and the price of the part. This is only possible for a single supplier/distributor and price with this fields. If you need to specify multiple suppliers/distributors or prices, you have to use JSON format (or CSV) with nested objects. +**Please note that the supplier fields is required, if you want to import prices or supplier product numbers.**. If the supplier is not specified, the price and supplier product number fields will be ignored: + +* **`supplier`**: The supplier of the part. Can be a path similar to the category field. +* **`supplier_product_number`** or **`supplier_part_number`** or * **`spn`**: The supplier product number of the part. +* **`price`**: The price of the part in the base currency of the database (by default euro). + +#### Example data +Here you can find some example data for the import of parts, you can use it as a template for your own import (especially the CSV file). + +* [Part import CSV example]({% link assets/usage/import_export/part_import_example.csv %}) with all possible fields + +## Export + +By default every user, who can read the datastructure, can also export the data of this datastructure, as this does not give the user any additional information. + +### Exporting data structures (categories, manufacturers, etc.) +You can export data structures (like categories, manufacturers, etc.) in the respective edit page (e.g. Tools Panel -> Edit -> Category). +If you select a certain datastructure from your list, you can export it (and optionally all sub-datastructures) in the "Export" tab. +If you want to export all datastructures of a certain type (e.g. all categories in your database), you can select the "Export all" function in the "Import / Export" tab of the "new element" page. + +You can select between the following export formats: +* **CSV** (Comma Separated Values): A semicolon separated list of values, where every line represents an element. This format can be imported into Excel or LibreOffice Calc and is easy to work with. However it does not support nested datastructures or sub data (like parameters, attachments, etc.), very well (many columns are generated, as every possible sub data is exported as a separate column). +* **JSON** (JavaScript Object Notation): A text-based format, which is easy to work with programming laguages. It supports nested datastructures and sub data (like parameters, attachments, etc.) very well. However it is not easy to work with in Excel or LibreOffice Calc and you maybe need to write some code to work with the exported data efficiently. +* **YAML** (Yet another Markup Language): Very similar to JSON +* **XML** (Extensible Markup Language): Good support with nested datastructures. Similar usecase as JSON and YAML. + +Also you can select between the following export levels: +* **Simple**: This will only export very basic information about the name (like the name, or description for parts) +* **Extended**: This will export all commonly used information about this datastructure (like notes, options, etc) +* **Full**: This will export all available information about this datastructure (like all parameters, attachments) + +Please note that the level will also be applied to all sub data or children elements. So if you select "Full" for a part, all the associated categories, manufacturers, footprints, etc. will also be exported with all available information, this can lead to very large export files. + +### Exporting parts +You can export parts in all part tables. Select the parts you want via the checkbox in the table line and select the export format and level in the appearing menu. + +See the section about exporting datastructures for more information about the export formats and levels. \ No newline at end of file diff --git a/src/Controller/AdminPages/BaseAdminController.php b/src/Controller/AdminPages/BaseAdminController.php index cf365d5c..28c9df60 100644 --- a/src/Controller/AdminPages/BaseAdminController.php +++ b/src/Controller/AdminPages/BaseAdminController.php @@ -58,6 +58,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Contracts\Translation\TranslatorInterface; @@ -338,20 +339,39 @@ abstract class BaseAdminController extends AbstractController $file = $import_form['file']->getData(); $data = $import_form->getData(); + if ($data['format'] === 'auto') { + $format = $importer->determineFormat($file->getClientOriginalExtension()); + if (null === $format) { + $this->addFlash('error', 'parts.import.flash.error.unknown_format'); + goto ret; + } + } else { + $format = $data['format']; + } + $options = [ - 'parent' => $data['parent'], - 'preserve_children' => $data['preserve_children'], - 'format' => $data['format'], - 'csv_separator' => $data['csv_separator'], + 'parent' => $data['parent'] ?? null, + 'preserve_children' => $data['preserve_children'] ?? false, + 'format' => $format, + 'class' => $this->entity_class, + 'csv_delimiter' => $data['csv_delimiter'], + 'abort_on_validation_error' => $data['abort_on_validation_error'], ]; $this->commentHelper->setMessage('Import '.$file->getClientOriginalName()); - $errors = $importer->fileToDBEntities($file, $this->entity_class, $options); + try { + $errors = $importer->importFileAndPersistToDB($file, $options); - foreach ($errors as $name => $error) { /** @var ConstraintViolationList $error */ - $this->addFlash('error', $name.':'.$error); + foreach ($errors as $name => $error) { + foreach ($error['violations'] as $violation) { + $this->addFlash('error', $name.': '.$violation->getMessage()); + } + } + } + catch (UnexpectedValueException $e) { + $this->addFlash('error', 'parts.import.flash.error.invalid_file'); } } @@ -382,6 +402,7 @@ abstract class BaseAdminController extends AbstractController $em->flush(); } + ret: return $this->renderForm($this->twig_template, [ 'entity' => $new_entity, 'form' => $form, diff --git a/src/Controller/PartImportExportController.php b/src/Controller/PartImportExportController.php new file mode 100644 index 00000000..22b5b528 --- /dev/null +++ b/src/Controller/PartImportExportController.php @@ -0,0 +1,141 @@ +. + */ + +namespace App\Controller; + +use App\Entity\Parts\Part; +use App\Form\AdminPages\ImportType; +use App\Services\ImportExportSystem\EntityExporter; +use App\Services\ImportExportSystem\EntityImporter; +use App\Services\LogSystem\EventCommentHelper; +use App\Services\Parts\PartsTableActionHandler; +use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use UnexpectedValueException; + +class PartImportExportController extends AbstractController +{ + private PartsTableActionHandler $partsTableActionHandler; + private EntityImporter $entityImporter; + private EventCommentHelper $commentHelper; + + public function __construct(PartsTableActionHandler $partsTableActionHandler, + EntityImporter $entityImporter, EventCommentHelper $commentHelper) + { + $this->partsTableActionHandler = $partsTableActionHandler; + $this->entityImporter = $entityImporter; + $this->commentHelper = $commentHelper; + } + + /** + * @Route("/parts/import", name="parts_import") + * @param Request $request + * @return Response + */ + public function importParts(Request $request): Response + { + $this->denyAccessUnlessGranted('@parts.import'); + + $import_form = $this->createForm(ImportType::class, ['entity_class' => Part::class]); + $import_form->handleRequest($request); + + if ($import_form->isSubmitted() && $import_form->isValid()) { + /** @var UploadedFile $file */ + $file = $import_form['file']->getData(); + $data = $import_form->getData(); + + if ($data['format'] === 'auto') { + $format = $this->entityImporter->determineFormat($file->getClientOriginalExtension()); + if (null === $format) { + $this->addFlash('error', 'parts.import.flash.error.unknown_format'); + goto ret; + } + } else { + $format = $data['format']; + } + + $options = [ + 'create_unknown_datastructures' => $data['create_unknown_datastructures'], + 'path_delimiter' => $data['path_delimiter'], + 'format' => $format, + 'part_category' => $data['part_category'], + 'class' => Part::class, + 'csv_delimiter' => $data['csv_delimiter'], + 'part_needs_review' => $data['part_needs_review'], + 'abort_on_validation_error' => $data['abort_on_validation_error'], + ]; + + $this->commentHelper->setMessage('Import '.$file->getClientOriginalName()); + + $entities = []; + + try { + $errors = $this->entityImporter->importFileAndPersistToDB($file, $options, $entities); + } catch (UnexpectedValueException $e) { + $this->addFlash('error', 'parts.import.flash.error.invalid_file'); + if ($e instanceof NotNormalizableValueException) { + $this->addFlash('error', $e->getMessage()); + } + goto ret; + } + + if (!isset($errors) || $errors) { + $this->addFlash('error', 'parts.import.flash.error'); + } else { + $this->addFlash('success', 'parts.import.flash.success'); + } + } + + + ret: + return $this->renderForm('parts/import/parts_import.html.twig', [ + 'import_form' => $import_form, + 'imported_entities' => $entities ?? [], + 'import_errors' => $errors ?? [], + ]); + } + + /** + * @Route("/parts/export", name="parts_export", methods={"GET"}) + * @return Response + */ + public function exportParts(Request $request, EntityExporter $entityExporter): Response + { + $ids = $request->query->get('ids', ''); + $parts = $this->partsTableActionHandler->idStringToArray($ids); + + if (empty($parts)) { + throw new \RuntimeException('No parts found!'); + } + + //Ensure that we have access to the parts + foreach ($parts as $part) { + $this->denyAccessUnlessGranted('read', $part); + } + + return $entityExporter->exportEntityFromRequest($parts, $request); + } +} \ No newline at end of file diff --git a/src/Controller/SelectAPIController.php b/src/Controller/SelectAPIController.php index 0f30b648..1b7784e4 100644 --- a/src/Controller/SelectAPIController.php +++ b/src/Controller/SelectAPIController.php @@ -95,6 +95,25 @@ class SelectAPIController extends AbstractController return $this->getResponseForClass(Project::class, false); } + /** + * @Route("/export_level", name="select_export_level") + */ + public function exportLevel(): Response + { + $entries = [ + 1 => $this->translator->trans('export.level.simple'), + 2 => $this->translator->trans('export.level.extended'), + 3 => $this->translator->trans('export.level.full'), + ]; + + return $this->json(array_map(function ($key, $value) { + return [ + 'text' => $value, + 'value' => $key, + ]; + }, array_keys($entries), $entries)); + } + /** * @Route("/label_profiles", name="select_label_profiles") * @return Response diff --git a/src/Entity/Attachments/AttachmentContainingDBElement.php b/src/Entity/Attachments/AttachmentContainingDBElement.php index 02aabadc..b550394e 100644 --- a/src/Entity/Attachments/AttachmentContainingDBElement.php +++ b/src/Entity/Attachments/AttachmentContainingDBElement.php @@ -29,6 +29,7 @@ use App\Entity\Contracts\HasMasterAttachmentInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * @ORM\MappedSuperclass() @@ -43,6 +44,7 @@ abstract class AttachmentContainingDBElement extends AbstractNamedDBElement impl * //@ORM\OneToMany(targetEntity="Attachment", mappedBy="element") * * Mapping is done in sub classes like part + * @Groups({"full"}) */ protected $attachments; diff --git a/src/Entity/Base/AbstractCompany.php b/src/Entity/Base/AbstractCompany.php index 7325bab6..af77106f 100644 --- a/src/Entity/Base/AbstractCompany.php +++ b/src/Entity/Base/AbstractCompany.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Entity\Base; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use function is_string; use Symfony\Component\Validator\Constraints as Assert; @@ -36,18 +37,21 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement /** * @var string The address of the company * @ORM\Column(type="string") + * @Groups({"full"}) */ protected string $address = ''; /** * @var string The phone number of the company * @ORM\Column(type="string") + * @Groups({"full"}) */ protected string $phone_number = ''; /** * @var string The fax number of the company * @ORM\Column(type="string") + * @Groups({"full"}) */ protected string $fax_number = ''; @@ -55,6 +59,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement * @var string The email address of the company * @ORM\Column(type="string") * @Assert\Email() + * @Groups({"full"}) */ protected string $email_address = ''; @@ -62,6 +67,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement * @var string The website of the company * @ORM\Column(type="string") * @Assert\Url() + * @Groups({"full"}) */ protected string $website = ''; diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php index dd736eac..97b77d53 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -38,20 +38,37 @@ use Symfony\Component\Serializer\Annotation\Groups; * @ORM\MappedSuperclass(repositoryClass="App\Repository\DBElementRepository") * * @DiscriminatorMap(typeProperty="type", mapping={ - * "attachment_type" = "App\Entity\AttachmentType", - * "attachment" = "App\Entity\Attachment", - * "category" = "App\Entity\Attachment", + * "attachment_type" = "App\Entity\Attachments\AttachmentType", + * "attachment" = "App\Entity\Attachments\Attachment", + * "attachment_type_attachment" = "App\Entity\Attachments\AttachmentTypeAttachment", + * "category_attachment" = "App\Entity\Attachments\CategoryAttachment", + * "currency_attachment" = "App\Entity\Attachments\CurrencyAttachment", + * "footprint_attachment" = "App\Entity\Attachments\FootprintAttachment", + * "group_attachment" = "App\Entity\Attachments\GroupAttachment", + * "label_attachment" = "App\Entity\Attachments\LabelAttachment", + * "manufacturer_attachment" = "App\Entity\Attachments\ManufacturerAttachment", + * "measurement_unit_attachment" = "App\Entity\Attachments\MeasurementUnitAttachment", + * "part_attachment" = "App\Entity\Attachments\PartAttachment", + * "project_attachment" = "App\Entity\Attachments\ProjectAttachment", + * "storelocation_attachment" = "App\Entity\Attachments\StorelocationAttachment", + * "supplier_attachment" = "App\Entity\Attachments\SupplierAttachment", + * "user_attachment" = "App\Entity\Attachments\UserAttachment", + * "category" = "App\Entity\Parts\Category", * "project" = "App\Entity\ProjectSystem\Project", * "project_bom_entry" = "App\Entity\ProjectSystem\ProjectBOMEntry", - * "footprint" = "App\Entity\Footprint", - * "group" = "App\Entity\Group", - * "manufacturer" = "App\Entity\Manufacturer", - * "orderdetail" = "App\Entity\Orderdetail", - * "part" = "App\Entity\Part", - * "pricedetail" = "App\Entity\Pricedetail", - * "storelocation" = "App\Entity\Storelocation", - * "supplier" = "App\Entity\Supplier", - * "user" = "App\Entity\User" + * "footprint" = "App\Entity\Parts\Footprint", + * "group" = "App\Entity\UserSystem\Group", + * "manufacturer" = "App\Entity\Parts\Manufacturer", + * "orderdetail" = "App\Entity\PriceInformations\Orderdetail", + * "part" = "App\Entity\Parts\Part", + * "pricedetail" = "App\Entity\PriceInformation\Pricedetail", + * "storelocation" = "App\Entity\Parts\Storelocation", + * "part_lot" = "App\Entity\Parts\PartLot", + * "currency" = "App\Entity\PriceInformations\Currency", + * "measurement_unit" = "App\Entity\Parts\MeasurementUnit", + * "parameter" = "App\Entity\Parts\AbstractParameter", + * "supplier" = "App\Entity\Parts\Supplier", + * "user" = "App\Entity\UserSystem\User" * }) */ abstract class AbstractDBElement implements JsonSerializable diff --git a/src/Entity/Base/AbstractNamedDBElement.php b/src/Entity/Base/AbstractNamedDBElement.php index 50c78f41..ddb758c0 100644 --- a/src/Entity/Base/AbstractNamedDBElement.php +++ b/src/Entity/Base/AbstractNamedDBElement.php @@ -42,7 +42,7 @@ abstract class AbstractNamedDBElement extends AbstractDBElement implements Named * @var string the name of this element * @ORM\Column(type="string") * @Assert\NotBlank() - * @Groups({"simple", "extended", "full"}) + * @Groups({"simple", "extended", "full", "import"}) */ protected string $name = ''; diff --git a/src/Entity/Base/AbstractPartsContainingDBElement.php b/src/Entity/Base/AbstractPartsContainingDBElement.php index f30819f5..be0b230a 100644 --- a/src/Entity/Base/AbstractPartsContainingDBElement.php +++ b/src/Entity/Base/AbstractPartsContainingDBElement.php @@ -23,13 +23,15 @@ declare(strict_types=1); namespace App\Entity\Base; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; /** * Class PartsContainingDBElement. * * @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository") */ -abstract class -AbstractPartsContainingDBElement extends AbstractStructuralDBElement +abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement { + /** @Groups({"full"}) */ + protected $parameters; } diff --git a/src/Entity/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php index 921826b0..457d40c6 100644 --- a/src/Entity/Base/AbstractStructuralDBElement.php +++ b/src/Entity/Base/AbstractStructuralDBElement.php @@ -63,7 +63,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement /** * @var string The comment info for this element * @ORM\Column(type="text") - * @Groups({"simple", "extended", "full"}) + * @Groups({"full", "import"}) */ protected string $comment = ''; @@ -71,6 +71,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement * @var bool If this property is set, this element can not be selected for part properties. * Useful if this element should be used only for grouping, sorting. * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $not_selectable = false; @@ -91,7 +92,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement /** * @var AbstractStructuralDBElement * @NoneOfItsChildren() - * @Groups({"include_parents"}) + * @Groups({"include_parents", "import"}) */ protected $parent = null; diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index 5a3f00e3..01aca08a 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -46,6 +46,7 @@ use App\Entity\Base\AbstractNamedDBElement; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; use LogicException; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use function sprintf; @@ -84,6 +85,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement * @var string The mathematical symbol for this specification. Can be rendered pretty later. Should be short * @Assert\Length(max=20) * @ORM\Column(type="string", nullable=false) + * @Groups({"full"}) */ protected string $symbol = ''; @@ -93,6 +95,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement * @Assert\LessThanOrEqual(propertyPath="value_typical", message="parameters.validator.min_lesser_typical") * @Assert\LessThan(propertyPath="value_max", message="parameters.validator.min_lesser_max") * @ORM\Column(type="float", nullable=true) + * @Groups({"full"}) */ protected ?float $value_min = null; @@ -100,6 +103,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement * @var float|null the typical value of this property * @Assert\Type({"null", "float"}) * @ORM\Column(type="float", nullable=true) + * @Groups({"full"}) */ protected ?float $value_typical = null; @@ -108,24 +112,29 @@ abstract class AbstractParameter extends AbstractNamedDBElement * @Assert\Type({"float", "null"}) * @Assert\GreaterThanOrEqual(propertyPath="value_typical", message="parameters.validator.max_greater_typical") * @ORM\Column(type="float", nullable=true) + * @Groups({"full"}) */ protected ?float $value_max = null; /** * @var string The unit in which the value values are given (e.g. V) * @ORM\Column(type="string", nullable=false) + * @Groups({"full"}) */ protected string $unit = ''; /** * @var string a text value for the given property * @ORM\Column(type="string", nullable=false) + * @Groups({"full"}) */ protected string $value_text = ''; /** * @var string the group this parameter belongs to * @ORM\Column(type="string", nullable=false, name="param_group") + * @Groups({"full"}) + * @Groups({"full"}) */ protected string $group = ''; diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index eac47877..8bcf32d2 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -27,6 +27,7 @@ use App\Entity\Base\AbstractPartsContainingDBElement; use App\Entity\Parameters\CategoryParameter; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -56,48 +57,56 @@ class Category extends AbstractPartsContainingDBElement /** * @var string * @ORM\Column(type="text") + * @Groups({"full", "import"}) */ protected string $partname_hint = ''; /** * @var string * @ORM\Column(type="text") + * @Groups({"full", "import"}) */ protected string $partname_regex = ''; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $disable_footprints = false; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $disable_manufacturers = false; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $disable_autodatasheets = false; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $disable_properties = false; /** * @var string * @ORM\Column(type="text") + * @Groups({"full", "import"}) */ protected string $default_description = ''; /** * @var string * @ORM\Column(type="text") + * @Groups({"full", "import"}) */ protected string $default_comment = ''; /** @@ -105,6 +114,7 @@ class Category extends AbstractPartsContainingDBElement * @ORM\OneToMany(targetEntity="App\Entity\Attachments\CategoryAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) * @ORM\OrderBy({"name" = "ASC"}) * @Assert\Valid() + * @Groups({"full"}) */ protected $attachments; @@ -112,6 +122,7 @@ class Category extends AbstractPartsContainingDBElement * @ORM\OneToMany(targetEntity="App\Entity\Parameters\CategoryParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"}) * @Assert\Valid() + * @Groups({"full"}) */ protected $parameters; diff --git a/src/Entity/Parts/MeasurementUnit.php b/src/Entity/Parts/MeasurementUnit.php index 1110f287..5ff9cef4 100644 --- a/src/Entity/Parts/MeasurementUnit.php +++ b/src/Entity/Parts/MeasurementUnit.php @@ -28,6 +28,7 @@ use App\Entity\Parameters\MeasurementUnitParameter; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -48,6 +49,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement * or m (for meters). * @ORM\Column(type="string", name="unit", nullable=true) * @Assert\Length(max=10) + * @Groups({"extended", "full", "import"}) */ protected ?string $unit = null; @@ -55,6 +57,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement * @var bool Determines if the amount value associated with this unit should be treated as integer. * Set to false, to measure continuous sizes likes masses or lengths. * @ORM\Column(type="boolean", name="is_integer") + * @Groups({"extended", "full", "import"}) */ protected bool $is_integer = false; @@ -63,6 +66,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement * Useful for sizes like meters. For this the unit must be set * @ORM\Column(type="boolean", name="use_si_prefix") * @Assert\Expression("this.isUseSIPrefix() == false or this.getUnit() != null", message="validator.measurement_unit.use_si_prefix_needs_unit") + * @Groups({"full", "import"}) */ protected bool $use_si_prefix = false; diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 8ffa7586..7f462930 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -40,6 +40,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -72,6 +73,7 @@ class Part extends AttachmentContainingDBElement * @Assert\Valid() * @ORM\OneToMany(targetEntity="App\Entity\Parameters\PartParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"}) + * @Groups({"full"}) */ protected $parameters; @@ -96,6 +98,7 @@ class Part extends AttachmentContainingDBElement * @ORM\OneToMany(targetEntity="App\Entity\Attachments\PartAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) * @ORM\OrderBy({"name" = "ASC"}) * @Assert\Valid() + * @Groups({"full"}) */ protected $attachments; diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index 1201f254..40db1fc2 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -31,6 +31,7 @@ use App\Validator\Constraints\ValidPartLot; use DateTime; use Doctrine\ORM\Mapping as ORM; use Exception; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -52,12 +53,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named /** * @var string A short description about this lot, shown in table * @ORM\Column(type="text") + * @Groups({"simple", "extended", "full", "import"}) */ protected string $description = ''; /** * @var string a comment stored with this lot * @ORM\Column(type="text") + * @Groups({"full", "import"}) */ protected string $comment = ''; @@ -65,6 +68,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named * @var ?DateTime Set a time until when the lot must be used. * Set to null, if the lot can be used indefinitely. * @ORM\Column(type="datetime", name="expiration_date", nullable=true) + * @Groups({"extended", "full", "import"}) */ protected ?DateTime $expiration_date = null; @@ -73,12 +77,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named * @ORM\ManyToOne(targetEntity="Storelocation") * @ORM\JoinColumn(name="id_store_location", referencedColumnName="id", nullable=true) * @Selectable() + * @Groups({"simple", "extended", "full", "import"}) */ protected ?Storelocation $storage_location = null; /** * @var bool If this is set to true, the instock amount is marked as not known * @ORM\Column(type="boolean") + * @Groups({"simple", "extended", "full", "import"}) */ protected bool $instock_unknown = false; @@ -86,12 +92,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named * @var float For continuous sizes (length, volume, etc.) the instock is saved here. * @ORM\Column(type="float") * @Assert\PositiveOrZero() + * @Groups({"simple", "extended", "full", "import"}) */ protected float $amount = 0.0; /** * @var bool determines if this lot was manually marked for refilling * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $needs_refill = false; diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index a798c305..482004b0 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits; use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -34,12 +35,14 @@ trait AdvancedPropertyTrait /** * @var bool Determines if this part entry needs review (for example, because it is work in progress) * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $needs_review = false; /** * @var string a comma separated list of tags, associated with the part * @ORM\Column(type="text") + * @Groups({"extended", "full", "import"}) */ protected string $tags = ''; @@ -47,6 +50,7 @@ trait AdvancedPropertyTrait * @var float|null how much a single part unit weighs in grams * @ORM\Column(type="float", nullable=true) * @Assert\PositiveOrZero() + * @Groups({"extended", "full", "import"}) */ protected ?float $mass = null; @@ -54,7 +58,7 @@ trait AdvancedPropertyTrait * @var string The internal part number of the part * @ORM\Column(type="string", length=100, nullable=true, unique=true) * @Assert\Length(max="100") - * + * @Groups({"extended", "full", "import"}) */ protected ?string $ipn = null; diff --git a/src/Entity/Parts/PartTraits/BasicPropertyTrait.php b/src/Entity/Parts/PartTraits/BasicPropertyTrait.php index c7c7fe50..0f88787f 100644 --- a/src/Entity/Parts/PartTraits/BasicPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/BasicPropertyTrait.php @@ -26,6 +26,7 @@ use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Validator\Constraints\Selectable; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; trait BasicPropertyTrait @@ -33,12 +34,14 @@ trait BasicPropertyTrait /** * @var string A text describing what this part does * @ORM\Column(type="text") + * @Groups({"simple", "extended", "full", "import"}) */ protected string $description = ''; /** * @var string A comment/note related to this part * @ORM\Column(type="text") + * @Groups({"extended", "full", "import"}) */ protected string $comment = ''; @@ -51,6 +54,7 @@ trait BasicPropertyTrait /** * @var bool true, if the part is marked as favorite * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $favorite = false; @@ -61,6 +65,7 @@ trait BasicPropertyTrait * @ORM\JoinColumn(name="id_category", referencedColumnName="id", nullable=false) * @Selectable() * @Assert\NotNull(message="validator.select_valid_category") + * @Groups({"simple", "extended", "full", "import"}) */ protected ?Category $category = null; @@ -69,6 +74,7 @@ trait BasicPropertyTrait * @ORM\ManyToOne(targetEntity="Footprint") * @ORM\JoinColumn(name="id_footprint", referencedColumnName="id") * @Selectable() + * @Groups({"simple", "extended", "full", "import"}) */ protected ?Footprint $footprint = null; diff --git a/src/Entity/Parts/PartTraits/InstockTrait.php b/src/Entity/Parts/PartTraits/InstockTrait.php index b79719ec..6035fd40 100644 --- a/src/Entity/Parts/PartTraits/InstockTrait.php +++ b/src/Entity/Parts/PartTraits/InstockTrait.php @@ -26,6 +26,7 @@ use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\PartLot; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -38,6 +39,7 @@ trait InstockTrait * @ORM\OneToMany(targetEntity="PartLot", mappedBy="part", cascade={"persist", "remove"}, orphanRemoval=true) * @Assert\Valid() * @ORM\OrderBy({"amount" = "DESC"}) + * @Groups({"extended", "full"}) */ protected $partLots; @@ -46,6 +48,7 @@ trait InstockTrait * Given in the partUnit. * @ORM\Column(type="float") * @Assert\PositiveOrZero() + * @Groups({"extended", "full", "import"}) */ protected float $minamount = 0; @@ -53,6 +56,7 @@ trait InstockTrait * @var ?MeasurementUnit the unit in which the part's amount is measured * @ORM\ManyToOne(targetEntity="MeasurementUnit") * @ORM\JoinColumn(name="id_part_unit", referencedColumnName="id", nullable=true) + * @Groups({"extended", "full", "import"}) */ protected ?MeasurementUnit $partUnit = null; diff --git a/src/Entity/Parts/PartTraits/ManufacturerTrait.php b/src/Entity/Parts/PartTraits/ManufacturerTrait.php index fc27ac14..d87e11cc 100644 --- a/src/Entity/Parts/PartTraits/ManufacturerTrait.php +++ b/src/Entity/Parts/PartTraits/ManufacturerTrait.php @@ -26,6 +26,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Validator\Constraints\Selectable; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -38,6 +39,7 @@ trait ManufacturerTrait * @ORM\ManyToOne(targetEntity="Manufacturer") * @ORM\JoinColumn(name="id_manufacturer", referencedColumnName="id") * @Selectable() + * @Groups({"simple","extended", "full", "import"}) */ protected ?Manufacturer $manufacturer = null; @@ -45,12 +47,14 @@ trait ManufacturerTrait * @var string the url to the part on the manufacturer's homepage * @ORM\Column(type="string") * @Assert\Url() + * @Groups({"full", "import"}) */ protected string $manufacturer_product_url = ''; /** * @var string The product number used by the manufacturer. If this is set to "", the name field is used. * @ORM\Column(type="string") + * @Groups({"extended", "full", "import"}) */ protected string $manufacturer_product_number = ''; @@ -58,6 +62,7 @@ trait ManufacturerTrait * @var string The production status of this part. Can be one of the specified ones. * @ORM\Column(type="string", length=255, nullable=true) * @Assert\Choice({"announced", "active", "nrfnd", "eol", "discontinued", ""}) + * @Groups({"extended", "full", "import"}) */ protected ?string $manufacturing_status = ''; diff --git a/src/Entity/Parts/PartTraits/OrderTrait.php b/src/Entity/Parts/PartTraits/OrderTrait.php index 60de1cba..97dc2713 100644 --- a/src/Entity/Parts/PartTraits/OrderTrait.php +++ b/src/Entity/Parts/PartTraits/OrderTrait.php @@ -24,6 +24,7 @@ namespace App\Entity\Parts\PartTraits; use App\Entity\PriceInformations\Orderdetail; use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use function count; use Doctrine\Common\Collections\Collection; @@ -39,6 +40,7 @@ trait OrderTrait * @ORM\OneToMany(targetEntity="App\Entity\PriceInformations\Orderdetail", mappedBy="part", cascade={"persist", "remove"}, orphanRemoval=true) * @Assert\Valid() * @ORM\OrderBy({"supplierpartnr" = "ASC"}) + * @Groups({"extended", "full"}) */ protected $orderdetails; diff --git a/src/Entity/Parts/Storelocation.php b/src/Entity/Parts/Storelocation.php index 53e71060..52f84f2d 100644 --- a/src/Entity/Parts/Storelocation.php +++ b/src/Entity/Parts/Storelocation.php @@ -27,6 +27,7 @@ use App\Entity\Base\AbstractPartsContainingDBElement; use App\Entity\Parameters\StorelocationParameter; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -70,20 +71,24 @@ class Storelocation extends AbstractPartsContainingDBElement /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $is_full = false; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $only_single_part = false; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"full", "import"}) */ protected bool $limit_to_existing_parts = false; + /** * @var Collection * @ORM\OneToMany(targetEntity="App\Entity\Attachments\StorelocationAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true) diff --git a/src/Entity/Parts/Supplier.php b/src/Entity/Parts/Supplier.php index 8183cf91..ae1e4ca1 100644 --- a/src/Entity/Parts/Supplier.php +++ b/src/Entity/Parts/Supplier.php @@ -31,6 +31,7 @@ use App\Validator\Constraints\Selectable; use Brick\Math\BigDecimal; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -74,6 +75,7 @@ class Supplier extends AbstractCompany /** * @var BigDecimal|null the shipping costs that have to be paid, when ordering via this supplier * @ORM\Column(name="shipping_costs", nullable=true, type="big_decimal", precision=11, scale=5) + * @Groups({"extended", "full", "import"}) * @BigDecimalPositiveOrZero() */ protected ?BigDecimal $shipping_costs = null; diff --git a/src/Entity/PriceInformations/Currency.php b/src/Entity/PriceInformations/Currency.php index e5d0439d..f2c44504 100644 --- a/src/Entity/PriceInformations/Currency.php +++ b/src/Entity/PriceInformations/Currency.php @@ -32,6 +32,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -60,6 +61,7 @@ class Currency extends AbstractStructuralDBElement * @var string the 3-letter ISO code of the currency * @ORM\Column(type="string") * @Assert\Currency() + * @Groups({"extended", "full", "import"}) */ protected string $iso_code = ""; diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php index e2f0187f..2d84c70c 100644 --- a/src/Entity/PriceInformations/Orderdetail.php +++ b/src/Entity/PriceInformations/Orderdetail.php @@ -34,6 +34,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -54,18 +55,21 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N * @ORM\OneToMany(targetEntity="Pricedetail", mappedBy="orderdetail", cascade={"persist", "remove"}, orphanRemoval=true) * @Assert\Valid() * @ORM\OrderBy({"min_discount_quantity" = "ASC"}) + * @Groups({"extended", "full", "import"}) */ protected $pricedetails; /** * @var string * @ORM\Column(type="string") + * @Groups({"extended", "full", "import"}) */ protected string $supplierpartnr = ''; /** * @var bool * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $obsolete = false; @@ -73,6 +77,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N * @var string * @ORM\Column(type="string") * @Assert\Url() + * @Groups({"full", "import"}) */ protected string $supplier_product_url = ''; @@ -89,6 +94,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Supplier", inversedBy="orderdetails") * @ORM\JoinColumn(name="id_supplier", referencedColumnName="id") * @Assert\NotNull(message="validator.orderdetail.supplier_must_not_be_null") + * @Groups({"extended", "full", "import"}) */ protected ?Supplier $supplier = null; diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php index 0229baf5..b1ac5155 100644 --- a/src/Entity/PriceInformations/Pricedetail.php +++ b/src/Entity/PriceInformations/Pricedetail.php @@ -32,6 +32,7 @@ use Brick\Math\RoundingMode; use DateTime; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -55,6 +56,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface * @var BigDecimal The price related to the detail. (Given in the selected currency) * @ORM\Column(type="big_decimal", precision=11, scale=5) * @BigDecimalPositive() + * @Groups({"extended", "full"}) */ protected BigDecimal $price; @@ -64,6 +66,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface * @ORM\ManyToOne(targetEntity="Currency", inversedBy="pricedetails") * @ORM\JoinColumn(name="id_currency", referencedColumnName="id", nullable=true) * @Selectable() + * @Groups({"extended", "full", "import"}) */ protected ?Currency $currency = null; @@ -71,6 +74,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface * @var float * @ORM\Column(type="float") * @Assert\Positive() + * @Groups({"extended", "full", "import"}) */ protected float $price_related_quantity = 1.0; @@ -78,6 +82,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface * @var float * @ORM\Column(type="float") * @Assert\Positive() + * @Groups({"extended", "full", "import"}) */ protected float $min_discount_quantity = 1.0; diff --git a/src/Entity/ProjectSystem/Project.php b/src/Entity/ProjectSystem/Project.php index 4c71b507..bf0dbfb0 100644 --- a/src/Entity/ProjectSystem/Project.php +++ b/src/Entity/ProjectSystem/Project.php @@ -30,6 +30,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -57,6 +58,7 @@ class Project extends AbstractStructuralDBElement /** * @ORM\OneToMany(targetEntity="ProjectBOMEntry", mappedBy="project", cascade={"persist", "remove"}, orphanRemoval=true) * @Assert\Valid() + * @Groups({"extended", "full"}) */ protected $bom_entries; @@ -69,6 +71,7 @@ class Project extends AbstractStructuralDBElement * @var string The current status of the project * @ORM\Column(type="string", length=64, nullable=true) * @Assert\Choice({"draft","planning","in_production","finished","archived"}) + * @Groups({"extended", "full"}) */ protected ?string $status = null; @@ -86,6 +89,7 @@ class Project extends AbstractStructuralDBElement /** * @ORM\Column(type="text", nullable=false, columnDefinition="DEFAULT ''") + * @Groups({"simple", "extended", "full"}) */ protected string $description = ''; diff --git a/src/Entity/UserSystem/Group.php b/src/Entity/UserSystem/Group.php index 0b29036f..30bec19c 100644 --- a/src/Entity/UserSystem/Group.php +++ b/src/Entity/UserSystem/Group.php @@ -30,6 +30,7 @@ use App\Validator\Constraints\ValidPermission; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; /** @@ -65,6 +66,7 @@ class Group extends AbstractStructuralDBElement implements HasPermissionsInterfa /** * @var bool If true all users associated with this group must have enabled some kind of 2 factor authentication * @ORM\Column(type="boolean", name="enforce_2fa") + * @Groups({"extended", "full", "import"}) */ protected $enforce2FA = false; /** @@ -79,6 +81,7 @@ class Group extends AbstractStructuralDBElement implements HasPermissionsInterfa * @var PermissionData|null * @ValidPermission() * @ORM\Embedded(class="PermissionData", columnPrefix="permissions_") + * @Groups({"full"}) */ protected ?PermissionData $permissions = null; diff --git a/src/Entity/UserSystem/User.php b/src/Entity/UserSystem/User.php index eddf9179..fd8c9054 100644 --- a/src/Entity/UserSystem/User.php +++ b/src/Entity/UserSystem/User.php @@ -33,6 +33,7 @@ use App\Validator\Constraints\ValidTheme; use Hslavich\OneloginSamlBundle\Security\User\SamlUserInterface; use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Serializer\Annotation\Groups; use Webauthn\PublicKeyCredentialUserEntity; use function count; use DateTime; @@ -74,6 +75,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe /** * @var bool Determines if the user is disabled (user can not log in) * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $disabled = false; @@ -81,6 +83,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @var string|null The theme * @ORM\Column(type="string", name="config_theme", nullable=true) * @ValidTheme() + * @Groups({"full", "import"}) */ protected ?string $theme = null; @@ -124,6 +127,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @ORM\ManyToOne(targetEntity="Group", inversedBy="users") * @ORM\JoinColumn(name="group_id", referencedColumnName="id") * @Selectable() + * @Groups({"extended", "full", "import"}) */ protected ?Group $group = null; @@ -137,6 +141,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @var string|null The timezone the user prefers * @ORM\Column(type="string", name="config_timezone", nullable=true) * @Assert\Timezone() + * @Groups({"full", "import"}) */ protected ?string $timezone = ''; @@ -144,6 +149,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @var string|null The language/locale the user prefers * @ORM\Column(type="string", name="config_language", nullable=true) * @Assert\Language() + * @Groups({"full", "import"}) */ protected ?string $language = ''; @@ -151,30 +157,35 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @var string|null The email address of the user * @ORM\Column(type="string", length=255, nullable=true) * @Assert\Email() + * @Groups({"simple", "extended", "full", "import"}) */ protected ?string $email = ''; /** * @var string|null The department the user is working * @ORM\Column(type="string", length=255, nullable=true) + * @Groups({"simple", "extended", "full", "import"}) */ protected ?string $department = ''; /** * @var string|null The last name of the User * @ORM\Column(type="string", length=255, nullable=true) + * @Groups({"simple", "extended", "full", "import"}) */ protected ?string $last_name = ''; /** * @var string|null The first name of the User * @ORM\Column(type="string", length=255, nullable=true) + * @Groups({"simple", "extended", "full", "import"}) */ protected ?string $first_name = ''; /** * @var bool True if the user needs to change password after log in * @ORM\Column(type="boolean") + * @Groups({"extended", "full", "import"}) */ protected bool $need_pw_change = true; @@ -206,6 +217,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe /** @var DateTime|null The time when the backup codes were generated * @ORM\Column(type="datetime", nullable=true) + * @Groups({"full"}) */ protected ?DateTime $backupCodesGenerationDate = null; @@ -228,6 +240,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency") * @ORM\JoinColumn(name="currency_id", referencedColumnName="id") * @Selectable() + * @Groups({"extended", "full", "import"}) */ protected $currency; @@ -235,6 +248,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe * @var PermissionData * @ValidPermission() * @ORM\Embedded(class="PermissionData", columnPrefix="permissions_") + * @Groups({"simple", "extended", "full", "import"}) */ protected ?PermissionData $permissions = null; @@ -247,6 +261,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe /** * @var bool True if the user was created by a SAML provider (and therefore cannot change its password) * @ORM\Column(type="boolean") + * @Groups({"extended", "full"}) */ protected bool $saml_user = false; diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php index 03e8800b..ded1da2f 100644 --- a/src/Form/AdminPages/ImportType.php +++ b/src/Form/AdminPages/ImportType.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace App\Form\AdminPages; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; use App\Form\Type\StructuralEntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -48,13 +50,14 @@ class ImportType extends AbstractType //Disable import if user is not allowed to create elements. $entity = new $data['entity_class'](); - $perm_name = 'create'; + $perm_name = 'import'; $disabled = !$this->security->isGranted($perm_name, $entity); $builder ->add('format', ChoiceType::class, [ 'choices' => [ + 'parts.import.format.auto' => 'auto', 'JSON' => 'json', 'XML' => 'xml', 'CSV' => 'csv', @@ -63,7 +66,7 @@ class ImportType extends AbstractType 'label' => 'export.format', 'disabled' => $disabled, ]) - ->add('csv_separator', TextType::class, [ + ->add('csv_delimiter', TextType::class, [ 'data' => ';', 'label' => 'import.csv_separator', 'disabled' => $disabled, @@ -78,6 +81,51 @@ class ImportType extends AbstractType ]); } + if ($entity instanceof Part) { + $builder->add('part_category', StructuralEntityType::class, [ + 'class' => Category::class, + 'required' => false, + 'label' => 'parts.import.part_category.label', + 'help' => 'parts.import.part_category.help', + 'disabled' => $disabled, + 'disable_not_selectable' => true, + 'allow_add' => true + ]); + $builder->add('part_needs_review', CheckboxType::class, [ + 'data' => false, + 'required' => false, + 'label' => 'parts.import.part_needs_review.label', + 'help' => 'parts.import.part_needs_review.help', + 'disabled' => $disabled, + ]); + } + + if ($entity instanceof AbstractStructuralDBElement) { + $builder->add('preserve_children', CheckboxType::class, [ + 'data' => true, + 'required' => false, + 'label' => 'import.preserve_children', + 'disabled' => $disabled, + ]); + } + + if ($entity instanceof Part) { + $builder->add('create_unknown_datastructures', CheckboxType::class, [ + 'data' => true, + 'required' => false, + 'label' => 'import.create_unknown_datastructures', + 'help' => 'import.create_unknown_datastructures.help', + 'disabled' => $disabled, + ]); + + $builder->add('path_delimiter', TextType::class, [ + 'data' => '->', + 'label' => 'import.path_delimiter', + 'help' => 'import.path_delimiter.help', + 'disabled' => $disabled, + ]); + } + $builder->add('file', FileType::class, [ 'label' => 'import.file', 'attr' => [ @@ -86,21 +134,15 @@ class ImportType extends AbstractType 'data-show-upload' => 'false', ], 'disabled' => $disabled, - ]) + ]); - ->add('preserve_children', CheckboxType::class, [ - 'data' => true, - 'required' => false, - 'label' => 'import.preserve_children', - 'disabled' => $disabled, - ]) - ->add('abort_on_validation_error', CheckboxType::class, [ - 'data' => true, - 'required' => false, - 'label' => 'import.abort_on_validation', - 'help' => 'import.abort_on_validation.help', - 'disabled' => $disabled, - ]) + $builder->add('abort_on_validation_error', CheckboxType::class, [ + 'data' => true, + 'required' => false, + 'label' => 'import.abort_on_validation', + 'help' => 'import.abort_on_validation.help', + 'disabled' => $disabled, + ]) //Buttons ->add('import', SubmitType::class, [ diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php index e23eda8f..d7dae474 100644 --- a/src/Repository/StructuralDBElementRepository.php +++ b/src/Repository/StructuralDBElementRepository.php @@ -29,6 +29,12 @@ use RecursiveIteratorIterator; class StructuralDBElementRepository extends NamedDBElementRepository { + /** + * @var array An array containing all new entities created by getNewEntityByPath. + * This is used to prevent creating multiple entities for the same path. + */ + private array $new_entity_cache = []; + /** * Finds all nodes without a parent node. They are our root nodes. * @@ -91,7 +97,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository } /** - * Creates a structure of AbsstractStructuralDBElements from a path separated by $separator, which splits the various levels. + * Creates a structure of AbstractStructuralDBElements from a path separated by $separator, which splits the various levels. * This function will try to use existing elements, if they are already in the database. If not, they will be created. * An array of the created elements will be returned, with the last element being the deepest element. * @param string $path @@ -108,14 +114,67 @@ class StructuralDBElementRepository extends NamedDBElementRepository continue; } - //See if we already have an element with this name and parent - $entity = $this->findOneBy(['name' => $name, 'parent' => $parent]); + //Use the cache to prevent creating multiple entities for the same path + $entity = $this->getNewEntityFromCache($name, $parent); + + //See if we already have an element with this name and parent in the database + if (!$entity) { + $entity = $this->findOneBy(['name' => $name, 'parent' => $parent]); + } if (null === $entity) { $class = $this->getClassName(); /** @var AbstractStructuralDBElement $entity */ $entity = new $class; $entity->setName($name); $entity->setParent($parent); + + $this->setNewEntityToCache($entity); + } + + $result[] = $entity; + $parent = $entity; + } + + return $result; + } + + private function getNewEntityFromCache(string $name, ?AbstractStructuralDBElement $parent): ?AbstractStructuralDBElement + { + $key = $parent ? $parent->getFullPath('%->%').'%->%'.$name : $name; + if (isset($this->new_entity_cache[$key])) { + return $this->new_entity_cache[$key]; + } + return null; + } + + private function setNewEntityToCache(AbstractStructuralDBElement $entity): void + { + $key = $entity->getFullPath('%->%'); + $this->new_entity_cache[$key] = $entity; + } + + /** + * Returns an element of AbstractStructuralDBElements queried from a path separated by $separator, which splits the various levels. + * An array of the created elements will be returned, with the last element being the deepest element. + * If no element was found, an empty array will be returned. + * @param string $path + * @param string $separator + * @return AbstractStructuralDBElement[] + */ + public function getEntityByPath(string $path, string $separator = '->'): array + { + $parent = null; + $result = []; + foreach (explode($separator, $path) as $name) { + $name = trim($name); + if ('' === $name) { + continue; + } + + //See if we already have an element with this name and parent + $entity = $this->findOneBy(['name' => $name, 'parent' => $parent]); + if (null === $entity) { + return []; } $result[] = $entity; diff --git a/src/Serializer/BigNumberNormalizer.php b/src/Serializer/BigNumberNormalizer.php new file mode 100644 index 00000000..fc352cf1 --- /dev/null +++ b/src/Serializer/BigNumberNormalizer.php @@ -0,0 +1,50 @@ +. + */ + +namespace App\Serializer; + +use Brick\Math\BigDecimal; +use Brick\Math\BigNumber; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class BigNumberNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof BigNumber; + } + + public function normalize($object, string $format = null, array $context = []): string + { + if (!$object instanceof BigNumber) { + throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!'); + } + + return (string) $object; + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php new file mode 100644 index 00000000..ef844acf --- /dev/null +++ b/src/Serializer/PartNormalizer.php @@ -0,0 +1,176 @@ +. + */ + +namespace App\Serializer; + +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\Storelocation; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; +use Brick\Math\BigDecimal; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +class PartNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +{ + private const DENORMALIZE_KEY_MAPPING = [ + 'notes' => 'comment', + 'quantity' => 'instock', + 'amount' => 'instock', + 'mpn' => 'manufacturer_product_number', + 'spn' => 'supplier_part_number', + 'supplier_product_number' => 'supplier_part_number', + 'storage_location' => 'storelocation', + ]; + + private ObjectNormalizer $normalizer; + private StructuralElementFromNameDenormalizer $locationDenormalizer; + + public function __construct(ObjectNormalizer $normalizer, StructuralElementFromNameDenormalizer $locationDenormalizer) + { + $this->normalizer = $normalizer; + $this->locationDenormalizer = $locationDenormalizer; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof Part; + } + + public function normalize($object, string $format = null, array $context = []) + { + if (!$object instanceof Part) { + throw new \InvalidArgumentException('This normalizer only supports Part objects!'); + } + + $data = $this->normalizer->normalize($object, $format, $context); + + //Remove type field for CSV export + if ($format === 'csv') { + unset($data['type']); + } + + $data['total_instock'] = $object->getAmountSum(); + + return $data; + } + + public function supportsDenormalization($data, string $type, string $format = null): bool + { + return is_array($data) && is_a($type, Part::class, true); + } + + private function normalizeKeys(array &$data): array + { + //Rename keys based on the mapping, while leaving the data untouched + foreach ($data as $key => $value) { + if (isset(self::DENORMALIZE_KEY_MAPPING[$key])) { + $data[self::DENORMALIZE_KEY_MAPPING[$key]] = $value; + unset($data[$key]); + } + } + + return $data; + } + + public function denormalize($data, string $type, string $format = null, array $context = []) + { + $this->normalizeKeys($data); + + //Empty IPN should be null, or we get a constraint error + if (isset($data['ipn']) && $data['ipn'] === '') { + $data['ipn'] = null; + } + + //Fill empty needs_review and needs_review_comment fields with false + if (empty($data['needs_review'])) { + $data['needs_review'] = false; + } + if (empty($data['favorite'])) { + $data['favorite'] = false; + } + if (empty($data['minamount'])) { + $data['minamount'] = 0.0; + } + + $object = $this->normalizer->denormalize($data, $type, $format, $context); + + if (!$object instanceof Part) { + throw new \InvalidArgumentException('This normalizer only supports Part objects!'); + } + + if ((isset($data['instock']) && trim($data['instock']) !== "") || (isset($data['storelocation']) && trim($data['storelocation']) !== "")) { + $partLot = new PartLot(); + + if (isset($data['instock']) && $data['instock'] !== "") { + //Replace comma with dot + $instock = (float) str_replace(',', '.', $data['instock']); + + $partLot->setAmount($instock); + } else { + $partLot->setInstockUnknown(true); + } + + if (isset($data['storelocation']) && $data['storelocation'] !== "") { + $location = $this->locationDenormalizer->denormalize($data['storelocation'], Storelocation::class, $format, $context); + $partLot->setStorageLocation($location); + } + + $object->addPartLot($partLot); + } + + if (isset($data['supplier']) && $data['supplier'] !== "") { + $supplier = $this->locationDenormalizer->denormalize($data['supplier'], Supplier::class, $format, $context); + + if ($supplier) { + $orderdetail = new Orderdetail(); + $orderdetail->setSupplier($supplier); + + if (isset($data['supplier_part_number']) && $data['supplier_part_number'] !== "") { + $orderdetail->setSupplierpartnr($data['supplier_part_number']); + } + + $object->addOrderdetail($orderdetail); + + if (isset($data['price']) && $data['price'] !== "") { + $pricedetail = new Pricedetail(); + $pricedetail->setMinDiscountQuantity(1); + $pricedetail->setPriceRelatedQuantity(1); + $price = BigDecimal::of(str_replace(',', '.', $data['price'])); + $pricedetail->setPrice($price); + + $orderdetail->addPricedetail($pricedetail); + } + } + } + + return $object; + } + + public function hasCacheableSupportsMethod(): bool + { + //Must be false, because we rely on is_array($data) in supportsDenormalization() + return false; + } +} \ No newline at end of file diff --git a/src/Serializer/StructuralElementFromNameDenormalizer.php b/src/Serializer/StructuralElementFromNameDenormalizer.php new file mode 100644 index 00000000..ac035946 --- /dev/null +++ b/src/Serializer/StructuralElementFromNameDenormalizer.php @@ -0,0 +1,77 @@ +. + */ + +namespace App\Serializer; + +use App\Entity\Base\AbstractStructuralDBElement; +use App\Form\Type\StructuralEntityType; +use App\Repository\StructuralDBElementRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class StructuralElementFromNameDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface +{ + private EntityManagerInterface $em; + + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + public function supportsDenormalization($data, string $type, string $format = null) + { + return is_string($data) && is_subclass_of($type, AbstractStructuralDBElement::class); + } + + public function denormalize($data, string $type, string $format = null, array $context = []) + { + //Retrieve the repository for the given type + /** @var StructuralDBElementRepository $repo */ + $repo = $this->em->getRepository($type); + + $path_delimiter = $context['path_delimiter'] ?? '->'; + + if ($context['create_unknown_datastructures'] ?? false) { + $elements = $repo->getNewEntityFromPath($data, $path_delimiter); + //Persist all new elements + foreach ($elements as $element) { + $this->em->persist($element); + } + if (empty($elements)) { + return null; + } + return end($elements); + } + + $elements = $repo->getEntityByPath($data, $path_delimiter); + if (empty($elements)) { + return null; + } + return end($elements); + } + + public function hasCacheableSupportsMethod(): bool + { + //Must be false, because we do a is_string check on data in supportsDenormalization + return false; + } +} \ No newline at end of file diff --git a/src/Serializer/StructuralElementNormalizer.php b/src/Serializer/StructuralElementNormalizer.php new file mode 100644 index 00000000..c412fabd --- /dev/null +++ b/src/Serializer/StructuralElementNormalizer.php @@ -0,0 +1,66 @@ +. + */ + +namespace App\Serializer; + +use App\Entity\Base\AbstractStructuralDBElement; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +class StructuralElementNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + private NormalizerInterface $normalizer; + + public function __construct(ObjectNormalizer $normalizer) + { + $this->normalizer = $normalizer; + } + + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof AbstractStructuralDBElement; + } + + public function normalize($object, string $format = null, array $context = []) + { + if (!$object instanceof AbstractStructuralDBElement) { + throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!'); + } + + $data = $this->normalizer->normalize($object, $format, $context); + + //Remove type field for CSV export + if ($format === 'csv') { + unset($data['type']); + } + + $data['full_name'] = $object->getFullPath('->'); + + return $data; + } + + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index 2d85097c..2b84b115 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\ImportExportSystem; use App\Entity\Base\AbstractNamedDBElement; +use Symfony\Component\OptionsResolver\OptionsResolver; use function in_array; use InvalidArgumentException; use function is_array; @@ -32,6 +33,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Serializer\SerializerInterface; +use function Symfony\Component\String\u; /** * Use this class to export an entity to multiple file formats. @@ -42,104 +44,136 @@ class EntityExporter public function __construct(SerializerInterface $serializer) { - /*$encoders = [new XmlEncoder(), new JsonEncoder(), new CSVEncoder(), new YamlEncoder()]; - $normalizers = [new ObjectNormalizer(), new DateTimeNormalizer()]; - $this->serializer = new Serializer($normalizers, $encoders); - $this->serializer-> */ $this->serializer = $serializer; } + protected function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('format', 'csv'); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); + + $resolver->setDefault('csv_delimiter', ';'); + $resolver->setAllowedTypes('csv_delimiter', 'string'); + + $resolver->setDefault('level', 'extended'); + $resolver->setAllowedValues('level', ['simple', 'extended', 'full']); + + $resolver->setDefault('include_children', false); + $resolver->setAllowedTypes('include_children', 'bool'); + } + /** - * Exports an Entity or an array of entities to multiple file formats. + * Export the given entities using the given options. + * @param AbstractNamedDBElement|AbstractNamedDBElement[] $entities The data to export + * @param array $options The options to use for exporting + * @return string The serialized data + */ + public function exportEntities($entities, array $options): string + { + if (!is_array($entities)) { + $entities = [$entities]; + } + + //Ensure that all entities are of type AbstractNamedDBElement + $entity_type = null; + foreach ($entities as $entity) { + if (!$entity instanceof AbstractNamedDBElement) { + throw new InvalidArgumentException('All entities must be of type AbstractNamedDBElement!'); + } + } + + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + $options = $resolver->resolve($options); + + //If include children is set, then we need to add the include_children group + $groups = [$options['level']]; + if ($options['include_children']) { + $groups[] = 'include_children'; + } + + return $this->serializer->serialize($entities, $options['format'], + [ + 'groups' => $groups, + 'as_collection' => true, + 'csv_delimiter' => $options['csv_delimiter'], + 'xml_root_node_name' => 'PartDBExport', + ] + ); + } + + /** + * Exports an Entity or an array of entities to multiple file formats. * * @param Request $request the request that should be used for option resolving - * @param AbstractNamedDBElement|object[] $entity + * @param AbstractNamedDBElement|object[] $entities * * @return Response the generated response containing the exported data * * @throws ReflectionException */ - public function exportEntityFromRequest($entity, Request $request): Response + public function exportEntityFromRequest($entities, Request $request): Response { - $format = $request->get('format') ?? 'json'; + $options = [ + 'format' => $request->get('format') ?? 'json', + 'level' => $request->get('level') ?? 'extended', + 'include_children' => $request->request->getBoolean('include_children') ?? false, + ]; - //Check if we have one of the supported formats - if (!in_array($format, ['json', 'csv', 'yaml', 'xml'], true)) { - throw new InvalidArgumentException('Given format is not supported!'); + if (!is_array($entities)) { + $entities = [$entities]; } - //Check export verbosity level - $level = $request->get('level') ?? 'extended'; - if (!in_array($level, ['simple', 'extended', 'full'], true)) { - throw new InvalidArgumentException('Given level is not supported!'); - } + //Do the serialization with the given options + $serialized_data = $this->exportEntities($entities, $options); - //Check for include children option - $include_children = $request->get('include_children') ?? false; + $response = new Response($serialized_data); - //Check which groups we need to export, based on level and include_children - $groups = [$level]; - if ($include_children) { - $groups[] = 'include_children'; - } + //Resolve the format + $optionsResolver = new OptionsResolver(); + $this->configureOptions($optionsResolver); + $options = $optionsResolver->resolve($options); + + //Determine the content type for the response //Plain text should work for all types $content_type = 'text/plain'; //Try to use better content types based on the format + $format = $options['format']; switch ($format) { case 'xml': $content_type = 'application/xml'; - break; case 'json': $content_type = 'application/json'; - break; } - - //Ensure that we always serialize an array. This makes it easier to import the data again. - if (is_array($entity)) { - $entity_array = $entity; - } else { - $entity_array = [$entity]; - } - - $serialized_data = $this->serializer->serialize($entity_array, $format, - [ - 'groups' => $groups, - 'as_collection' => true, - 'csv_delimiter' => ';', //Better for Excel - 'xml_root_node_name' => 'PartDBExport', - ]); - - $response = new Response($serialized_data); - $response->headers->set('Content-Type', $content_type); //If view option is not specified, then download the file. if (!$request->get('view')) { - if ($entity instanceof AbstractNamedDBElement) { - $entity_name = $entity->getName(); - } elseif (is_array($entity)) { - if (empty($entity)) { - throw new InvalidArgumentException('$entity must not be empty!'); - } - //Use the class name of the first element for the filename - $reflection = new ReflectionClass($entity[0]); - $entity_name = $reflection->getShortName(); + //Determine the filename + //When we only have one entity, then we can use the name of the entity + if (count($entities) === 1) { + $entity_name = $entities[0]->getName(); } else { - throw new InvalidArgumentException('$entity type is not supported!'); + //Use the class name of the first element for the filename otherwise + $reflection = new ReflectionClass($entities[0]); + $entity_name = $reflection->getShortName(); } + $level = $options['level']; + $filename = 'export_'.$entity_name.'_'.$level.'.'.$format; // Create the disposition of the file $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename, - $string = preg_replace('![^'.preg_quote('-', '!').'a-z0-_9\s]+!', '', strtolower($filename)) + u($filename)->ascii()->toString(), ); // Set the content disposition $response->headers->set('Content-Disposition', $disposition); diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index d6e00570..c798f9f5 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -24,6 +24,9 @@ namespace App\Services\ImportExportSystem; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; +use Symplify\EasyCodingStandard\ValueObject\Option; use function count; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; @@ -48,7 +51,7 @@ class EntityImporter /** * Creates many entries at once, based on a (text) list of name. - * The created enties are not persisted to database yet, so you have to do it yourself. + * The created entities are not persisted to database yet, so you have to do it yourself. * * @param string $lines The list of names seperated by \n * @param string $class_name The name of the class for which the entities should be created @@ -130,87 +133,38 @@ class EntityImporter } /** - * This methods deserializes the given file and saves it database. - * The imported elements will be checked (validated) before written to database. - * - * @param File $file the file that should be used for importing - * @param string $class_name the class name of the enitity that should be imported - * @param array $options options for the import process - * - * @return array An associative array containing an ConstraintViolationList and the entity name as key are returned, - * if an error happened during validation. When everything was successfull, the array should be empty. + * Import data from a string. + * @param string $data The serialized data which should be imported + * @param array $options The options for the import process + * @param array $errors An array which will be filled with the validation errors, if any occurs during import + * @return array An array containing all valid imported entities */ - public function fileToDBEntities(File $file, string $class_name, array $options = []): array + public function importString(string $data, array $options = [], array &$errors = []): array { $resolver = new OptionsResolver(); $this->configureOptions($resolver); - $options = $resolver->resolve($options); - $entities = $this->fileToEntityArray($file, $class_name, $options); - - $errors = []; - - //Iterate over each $entity write it to DB. - foreach ($entities as $entity) { - /** @var AbstractStructuralDBElement $entity */ - //Move every imported entity to the selected parent - $entity->setParent($options['parent']); - - //Validate entity - $tmp = $this->validator->validate($entity); - - //When no validation error occured, persist entity to database (cascade must be set in entity) - if (null === $tmp) { - $this->em->persist($entity); - } else { //Log validation errors to global log. - $errors[$entity->getFullPath()] = $tmp; - } + if (!is_a($options['class'], AbstractNamedDBElement::class, true)) { + throw new InvalidArgumentException('$class_name must be an AbstractNamedDBElement type!'); } - //Save changes to database, when no error happened, or we should continue on error. - if (empty($errors) || false === $options['abort_on_validation_error']) { - $this->em->flush(); - } - - return $errors; - } - - /** - * This method converts (deserialize) a (uploaded) file to an array of entities with the given class. - * - * The imported elements will NOT be validated. If you want to use the result array, you have to validate it by yourself. - * - * @param File $file the file that should be used for importing - * @param string $class_name the class name of the enitity that should be imported - * @param array $options options for the import process - * - * @return array an array containing the deserialized elements - */ - public function fileToEntityArray(File $file, string $class_name, array $options = []): array - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - - $options = $resolver->resolve($options); - - //Read file contents - $content = file_get_contents($file->getRealPath()); - - $groups = ['simple']; + $groups = ['import']; //We can only import data, that is marked with the group "import" //Add group when the children should be preserved if ($options['preserve_children']) { $groups[] = 'include_children'; } //The [] behind class_name denotes that we expect an array. - $entities = $this->serializer->deserialize($content, $class_name.'[]', $options['format'], + $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'], [ 'groups' => $groups, - 'csv_delimiter' => $options['csv_separator'], + 'csv_delimiter' => $options['csv_delimiter'], + 'create_unknown_datastructures' => $options['create_unknown_datastructures'], + 'path_delimiter' => $options['path_delimiter'], ]); - //Ensure we have an array of entitity elements. + //Ensure we have an array of entity elements. if (!is_array($entities)) { $entities = [$entities]; } @@ -220,18 +174,143 @@ class EntityImporter $this->correctParentEntites($entities, null); } + //Set the parent of the imported elements to the given options + foreach ($entities as $entity) { + if ($entity instanceof AbstractStructuralDBElement) { + $entity->setParent($options['parent']); + } + if ($entity instanceof Part) { + if ($options['part_category']) { + $entity->setCategory($options['part_category']); + } + if ($options['part_needs_review']) { + $entity->setNeedsReview(true); + } + } + } + + //Validate the entities + $errors = []; + + //Iterate over each $entity write it to DB. + foreach ($entities as $key => $entity) { + //Validate entity + $tmp = $this->validator->validate($entity); + + if (count($tmp) > 0) { //Log validation errors to global log. + $name = $entity instanceof AbstractStructuralDBElement ? $entity->getFullPath() : $entity->getName(); + + $errors[$name] = [ + 'violations' => $tmp, + 'entity' => $entity, + ]; + + //Remove the invalid entity from the array + unset($entities[$key]); + } + } + return $entities; } - protected function configureOptions(OptionsResolver $resolver): void + protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setDefaults([ - 'csv_separator' => ';', - 'format' => 'json', + 'csv_delimiter' => ';', //The separator to use when importing csv files + 'format' => 'json', //The format of the file that should be imported + 'class' => AbstractNamedDBElement::class, 'preserve_children' => true, - 'parent' => null, + 'parent' => null, //The parent element to which the imported elements should be added 'abort_on_validation_error' => true, + 'part_category' => null, + 'part_needs_review' => false, //If true, the imported parts will be marked as "needs review", otherwise the value from the file will be used + 'create_unknown_datastructures' => true, //If true, unknown datastructures (categories, footprints, etc.) will be created on the fly + 'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element ]); + + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); + $resolver->setAllowedTypes('csv_delimiter', 'string'); + $resolver->setAllowedTypes('preserve_children', 'bool'); + $resolver->setAllowedTypes('class', 'string'); + $resolver->setAllowedTypes('part_category', [Category::class, 'null']); + $resolver->setAllowedTypes('part_needs_review', 'bool'); + + return $resolver; + } + + /** + * This method deserializes the given file and writes the entities to the database (and flush the db). + * The imported elements will be checked (validated) before written to database. + * + * @param File $file the file that should be used for importing + * @param array $options options for the import process + * @param AbstractNamedDBElement[] $entities The imported entities are returned in this array + * + * @return array An associative array containing an ConstraintViolationList and the entity name as key are returned, + * if an error happened during validation. When everything was successfully, the array should be empty. + */ + public function importFileAndPersistToDB(File $file, array $options = [], array &$entities = []): array + { + $options = $this->configureOptions(new OptionsResolver())->resolve($options); + + $errors = []; + $entities = $this->importFile($file, $options, $errors); + + //When we should abort on validation error, do nothing and return the errors + if (!empty($errors) && $options['abort_on_validation_error']) { + return $errors; + } + + //Iterate over each $entity write it to DB (the invalid entities were already filtered out). + foreach ($entities as $entity) { + $this->em->persist($entity); + } + + //Save changes to database, when no error happened, or we should continue on error. + $this->em->flush(); + + return $errors; + } + + /** + * This method converts (deserialize) a (uploaded) file to an array of entities with the given class. + * The imported elements are not persisted to database yet, so you have to do it yourself. + * + * @param File $file the file that should be used for importing + * @param array $options options for the import process + * + * @return array an array containing the deserialized elements + */ + public function importFile(File $file, array $options = [], array &$errors = []): array + { + return $this->importString($file->getContent(), $options, $errors); + } + + + /** + * Determines the format to import based on the file extension. + * @param string $extension The file extension to use + * @return string The format to use (json, xml, csv, yaml), or null if the extension is unknown + */ + public function determineFormat(string $extension): ?string + { + //Convert the extension to lower case + $extension = strtolower($extension); + + switch ($extension) { + case 'json': + return 'json'; + case 'xml': + return 'xml'; + case 'csv': + case 'tsv': + return 'csv'; + case 'yaml': + case 'yml': + return 'yaml'; + default: + return null; + } } /** diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 8b695141..3061d2f3 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -102,6 +102,34 @@ final class PartsTableActionHandler ); } + //When action starts with "export_" we have to redirect to the export controller + $matches = []; + if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + switch ($target_id) { + case 1: + default: + $level = 'simple'; + break; + case 2: + $level = 'extended'; + break; + case 3: + $level = 'full'; + break; + } + + + return new RedirectResponse( + $this->urlGenerator->generate('parts_export', [ + 'format' => $matches[1], + 'level' => $level, + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 38018c6e..841c2bd4 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -143,6 +143,12 @@ class ToolsTreeBuilder $this->urlGenerator->generate('tools_ic_logos') ))->setIcon('fa-treeview fa-fw fa-solid fa-flag'); } + if ($this->security->isGranted('@parts.import')) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('parts.import.title'), + $this->urlGenerator->generate('parts_import') + ))->setIcon('fa-treeview fa-fw fa-solid fa-file-import'); + } return $nodes; } diff --git a/src/Services/UserSystem/PermissionManager.php b/src/Services/UserSystem/PermissionManager.php index 717c0bac..618e473a 100644 --- a/src/Services/UserSystem/PermissionManager.php +++ b/src/Services/UserSystem/PermissionManager.php @@ -271,6 +271,28 @@ class PermissionManager } } + /** + * This function sets all operations of the given permission to the given value, except the ones listed in the except array. + * @param HasPermissionsInterface $perm_holder + * @param string $permission + * @param bool|null $new_value + * @param array $except + * @return void + */ + public function setAllOperationsOfPermissionExcept(HasPermissionsInterface $perm_holder, string $permission, ?bool $new_value, array $except): void + { + if (!$this->isValidPermission($permission)) { + throw new InvalidArgumentException(sprintf('A permission with that name is not existing! Got %s.', $permission)); + } + + foreach ($this->permission_structure['perms'][$permission]['operations'] as $op_key => $op) { + if (in_array($op_key, $except, true)) { + continue; + } + $this->setPermission($perm_holder, $permission, $op_key, $new_value); + } + } + protected function generatePermissionStructure() { $cache = new ConfigCache($this->cache_file, $this->is_debug); diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index 732e29f5..3340f9bb 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -93,6 +93,20 @@ class PermissionPresetsHelper //Allow access to system log and server infos $this->permissionResolver->setPermission($perm_holder, 'system', 'show_logs', PermissionData::ALLOW); $this->permissionResolver->setPermission($perm_holder, 'system', 'server_infos', PermissionData::ALLOW); + + //Allow import for all datastructures + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'parts', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'parts_stock', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'categories', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'storelocations', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'footprints', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'manufacturers', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'attachment_types', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'currencies', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'measurement_units', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW); + } private function editor(HasPermissionsInterface $permHolder): HasPermissionsInterface @@ -101,17 +115,18 @@ class PermissionPresetsHelper $this->readOnly($permHolder); //Set datastructures - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts', PermissionData::ALLOW); + //By default import is restricted to administrators, as it allows to fill up the database very fast + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'parts', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts_stock', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'categories', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'storelocations', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'footprints', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'manufacturers', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'attachment_types', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'currencies', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'measurement_units', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'suppliers', PermissionData::ALLOW); - $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'projects', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'categories', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'storelocations', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'footprints', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'manufacturers', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'attachment_types', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'currencies', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'measurement_units', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'suppliers', PermissionData::ALLOW, ['import']); + $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'projects', PermissionData::ALLOW, ['import']); //Attachments permissions $this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW); diff --git a/templates/admin/base_admin.html.twig b/templates/admin/base_admin.html.twig index df510908..72a6ec60 100644 --- a/templates/admin/base_admin.html.twig +++ b/templates/admin/base_admin.html.twig @@ -179,7 +179,7 @@
{% trans %}export_all.label{% endtrans %} - {% include 'admin/_export_form.html.twig' with {'path' : path('attachment_type_export_all')} %} + {% include 'admin/_export_form.html.twig' with {'path' : path(route_base ~ '_export_all')} %}
diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index e8a91c7f..fcc38453 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -63,6 +63,12 @@ + + + + + +