Merge branch 'project_system'

This commit is contained in:
Jan Böhmer 2023-01-08 23:27:11 +01:00
commit 794f5177ab
113 changed files with 4785 additions and 580 deletions

View file

@ -26,6 +26,7 @@ export default class extends Controller {
static values = {
deleteMessage: String,
prototype: String,
rowsToDelete: Number, //How many rows (including the current one) shall be deleted after the current row
}
static targets = ["target"];
@ -125,7 +126,17 @@ export default class extends Controller {
const del = () => {
const target = event.target;
//Remove the row element from the table
target.closest("tr").remove();
const current_row = target.closest("tr");
for(let i = this.rowsToDeleteValue; i > 1; i--) {
let nextSibling = current_row.nextElementSibling;
//Ensure that nextSibling is really a tr
if (nextSibling && nextSibling.tagName === "TR") {
nextSibling.remove();
}
}
//Finally delete the current row
current_row.remove();
}
if(this.deleteMessageValue) {

View file

@ -0,0 +1,68 @@
import {Controller} from "@hotwired/stimulus";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import {marked} from "marked";
export default class extends Controller {
_tomSelect;
connect() {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
preload: "focus",
render: {
item: (data, escape) => {
return '<span>' + (data.image ? "<img style='height: 1.5rem; margin-right: 5px;' ' src='" + data.image + "'/>" : "") + escape(data.name) + '</span>';
},
option: (data, escape) => {
if(data.text) {
return '<span>' + escape(data.text) + '</span>';
}
let tmp = '<div class="row m-0">' +
"<div class='col-2 p-0 d-flex align-items-center'>" +
(data.image ? "<img class='typeahead-image' src='" + data.image + "'/>" : "") +
"</div>" +
"<div class='col-10'>" +
'<h6 class="m-0">' + escape(data.name) + '</h6>' +
(data.description ? '<p class="m-0">' + marked.parseInline(data.description) + '</p>' : "") +
(data.category ? '<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span> ' + escape(data.category) : "");
if (data.footprint) { //If footprint is defined for the part show it next to the category
tmp += ' <span class="fa-solid fa-microchip fa-fw"></span> ' + escape(data.footprint);
}
return tmp + '</p>' +
'</div></div>';
}
}
};
if (this.element.dataset.autocomplete) {
const base_url = this.element.dataset.autocomplete;
settings.valueField = "id";
settings.load = (query, callback) => {
const url = base_url.replace('__QUERY__', encodeURIComponent(query));
fetch(url)
.then(response => response.json())
.then(json => {callback(json);})
.catch(() => {
callback()
});
};
this._tomSelect = new TomSelect(this.element, settings);
//this._tomSelect.clearOptions();
}
}
}

View file

@ -0,0 +1,64 @@
import {Controller} from "@hotwired/stimulus";
import {Modal} from "bootstrap";
export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
}
_handleModalOpen(event) {
// Button that triggered the modal
const button = event.relatedTarget;
const amountInput = this.element.querySelector('input[name="amount"]');
// Extract info from button attributes
const action = button.getAttribute('data-action');
const lotID = button.getAttribute('data-lot-id');
const lotAmount = button.getAttribute('data-lot-amount');
//Set the action and lotID inputs in the form
this.element.querySelector('input[name="action"]').setAttribute('value', action);
this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID);
//Set the title
const titleElement = this.element.querySelector('.modal-title');
switch (action) {
case 'withdraw':
titleElement.innerText = titleElement.getAttribute('data-withdraw');
break;
case 'add':
titleElement.innerText = titleElement.getAttribute('data-add');
break;
case 'move':
titleElement.innerText = titleElement.getAttribute('data-move');
break;
}
//Hide the move to lot select, if the action is not move (and unhide it, if it is)
const moveToLotSelect = this.element.querySelector('#withdraw-modal-move-to');
if (action === 'move') {
moveToLotSelect.classList.remove('d-none');
} else {
moveToLotSelect.classList.add('d-none');
}
//First unhide all move to lot options and then hide the currently selected lot
const moveToLotOptions = moveToLotSelect.querySelectorAll('input[type="radio"]');
moveToLotOptions.forEach(option => option.parentElement.classList.remove('d-none'));
moveToLotOptions.forEach(option => {
if (option.getAttribute('value') === lotID) {
option.parentElement.classList.add('d-none');
option.selected = false;
}
});
//For adding parts there is no limit on the amount to add
if (action == 'add') {
amountInput.removeAttribute('max');
} else { //Every other action is limited to the amount of parts in the lot
amountInput.setAttribute('max', lotAmount);
}
}
}

View file

@ -22,8 +22,17 @@
class RegisterEventHelper {
constructor() {
this.registerTooltips();
this.registerSpecialCharInput();
this.registerModalDropRemovalOnFormSubmit()
}
registerModalDropRemovalOnFormSubmit() {
//Remove all modal backdrops, before rendering the new page.
document.addEventListener('turbo:before-render', event => {
document.querySelector('.modal-backdrop').remove();
});
}
registerLoadHandler(fn) {
@ -35,7 +44,7 @@ class RegisterEventHelper {
this.registerLoadHandler(() => {
$(".tooltip").remove();
//Exclude dropdown buttons from tooltips, otherwise we run into endless errors from bootstrap (bootstrap.esm.js:614 Bootstrap doesn't allow more than one instance per element. Bound instance: bs.dropdown.)
$('a[title], button[title]:not([data-bs-toggle="dropdown"]), span[title], h6[title], h3[title], i.fas[title]')
$('a[title], button[title]:not([data-bs-toggle="dropdown"]), p[title], span[title], h6[title], h3[title], i.fas[title]')
//@ts-ignore
.tooltip("hide").tooltip({container: "body", placement: "auto", boundary: 'window'});
});

View file

@ -8,11 +8,11 @@ doctrine:
types:
datetime:
class: App\Helpers\UTCDateTimeType
class: App\Doctrine\Types\UTCDateTimeType
date:
class: App\Helpers\UTCDateTimeType
class: App\Doctrine\Types\UTCDateTimeType
big_decimal:
class: App\Helpers\BigDecimalType
class: App\Doctrine\Types\BigDecimalType
schema_filter: ~^(?!internal)~
# Only enable this when needed

View file

@ -27,7 +27,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
'currencies.read', 'attachment_types.read', 'measurement_units.read']
edit:
label: "perm.edit"
alsoSet: 'read'
alsoSet: ['read', 'parts_stock.withdraw', 'parts_stock.add', 'parts_stock.move']
create:
label: "perm.create"
alsoSet: ['read', 'edit']
@ -44,6 +44,18 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
label: "perm.revert_elements"
alsoSet: ["read", "edit", "create", "delete", "show_history"]
parts_stock:
group: "data"
label: "perm.parts_stock"
operations:
withdraw:
label: "perm.parts_stock.withdraw"
add:
label: "perm.parts_stock.add"
move:
label: "perm.parts_stock.move"
storelocations: &PART_CONTAINING
label: "perm.storelocations"
group: "data"
@ -81,9 +93,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
<<: *PART_CONTAINING
label: "perm.part.manufacturers"
devices:
projects:
<<: *PART_CONTAINING
label: "perm.part.devices"
label: "perm.projects"
attachment_types:
<<: *PART_CONTAINING

View file

@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230108165410 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Create schema for project system. For SQLite indices and foreign keys are created for the whole DB.';
}
public function mySQLUp(Schema $schema): void
{
//Rename the table from devices to projects
$this->addSql('ALTER TABLE devices RENAME TO projects');
$this->addSql('ALTER TABLE device_parts RENAME TO project_bom_entries');
$this->addSql('ALTER TABLE parts ADD built_project_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
$this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_AFC547992F180363');
$this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_AFC54799C22F6CC4');
$this->addSql('ALTER TABLE project_bom_entries ADD price_currency_id INT DEFAULT NULL, ADD name VARCHAR(255) DEFAULT NULL, ADD comment LONGTEXT NOT NULL, ADD price NUMERIC(11, 5) DEFAULT NULL COMMENT \'(DC2Type:big_decimal)\', ADD last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, ADD datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE quantity quantity DOUBLE PRECISION NOT NULL');
$this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id)');
$this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)');
$this->addSql('DROP INDEX idx_afc547992f180363 ON project_bom_entries');
$this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)');
$this->addSql('DROP INDEX idx_afc54799c22f6cc4 ON project_bom_entries');
$this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)');
$this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_AFC547992F180363 FOREIGN KEY (id_device) REFERENCES projects (id)');
$this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_AFC54799C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id)');
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_11074E9A6DEDCEC2');
//On old DBs migrated from the legacy Part-DB version the key devices_parent_id_fk is still existing
if ($this->getOldDBVersion() === 99) {
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY devices_parent_id_fk');
}
$this->addSql('ALTER TABLE projects ADD status VARCHAR(64) DEFAULT NULL, ADD description LONGTEXT NOT NULL');
$this->addSql('ALTER TABLE projects RENAME INDEX idx_11074e9a727aca70 TO IDX_5C93B3A4727ACA70');
$this->addSql('ALTER TABLE projects RENAME INDEX idx_11074e9a6dedcec2 TO IDX_5C93B3A46DEDCEC2');
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_11074E9A6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id)');
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE `parts` DROP FOREIGN KEY FK_6940A7FEE8AE70D9');
$this->addSql('DROP INDEX UNIQ_6940A7FEE8AE70D9 ON `parts`');
$this->addSql('ALTER TABLE `parts` DROP built_project_id');
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A4727ACA70');
$this->addSql('ALTER TABLE projects DROP FOREIGN KEY FK_5C93B3A46DEDCEC2');
$this->addSql('ALTER TABLE projects DROP status, DROP description');
$this->addSql('DROP INDEX idx_5c93b3a4727aca70 ON projects');
$this->addSql('CREATE INDEX IDX_11074E9A727ACA70 ON projects (parent_id)');
$this->addSql('DROP INDEX idx_5c93b3a46dedcec2 ON projects');
$this->addSql('CREATE INDEX IDX_11074E9A6DEDCEC2 ON projects (id_preview_attachement)');
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id)');
$this->addSql('ALTER TABLE projects ADD CONSTRAINT FK_5C93B3A46DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES `attachments` (id)');
$this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD313FFDCD60');
$this->addSql('DROP INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries');
$this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD312F180363');
$this->addSql('ALTER TABLE project_bom_entries DROP FOREIGN KEY FK_1AA2DD31C22F6CC4');
$this->addSql('ALTER TABLE project_bom_entries DROP price_currency_id, DROP name, DROP comment, DROP price, DROP last_modified, DROP datetime_added, CHANGE quantity quantity INT NOT NULL');
$this->addSql('DROP INDEX idx_1aa2dd312f180363 ON project_bom_entries');
$this->addSql('CREATE INDEX IDX_AFC547992F180363 ON project_bom_entries (id_device)');
$this->addSql('DROP INDEX idx_1aa2dd31c22f6cc4 ON project_bom_entries');
$this->addSql('CREATE INDEX IDX_AFC54799C22F6CC4 ON project_bom_entries (id_part)');
$this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD312F180363 FOREIGN KEY (id_device) REFERENCES projects (id)');
$this->addSql('ALTER TABLE project_bom_entries ADD CONSTRAINT FK_1AA2DD31C22F6CC4 FOREIGN KEY (id_part) REFERENCES `parts` (id)');
$this->addSql('ALTER TABLE projects RENAME TO devices');
$this->addSql('ALTER TABLE project_bom_entries RENAME TO device_parts');
}
public function sqLiteUp(Schema $schema): void
{
//Rename the table from devices to projects
$this->addSql('ALTER TABLE devices RENAME TO projects');
$this->addSql('ALTER TABLE device_parts RENAME TO project_bom_entries');
$this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies');
$this->addSql('DROP TABLE currencies');
$this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal)
, iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C446936DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies');
$this->addSql('DROP TABLE __temp__currencies');
$this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)');
$this->addSql('CREATE INDEX currency_idx_name ON currencies (name)');
$this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)');
$this->addSql('CREATE INDEX IDX_37C446936DEDCEC2 ON currencies (id_preview_attachement)');
$this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM groups');
$this->addSql('DROP TABLE groups');
$this->addSql('CREATE TABLE groups (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --(DC2Type:json)
, CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D39706DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO groups (id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups');
$this->addSql('DROP TABLE __temp__groups');
$this->addSql('CREATE INDEX IDX_F06D39706DEDCEC2 ON groups (id_preview_attachement)');
$this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON groups (parent_id)');
$this->addSql('CREATE INDEX group_idx_name ON groups (name)');
$this->addSql('CREATE INDEX group_idx_parent_name ON groups (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM log');
$this->addSql('DROP TABLE log');
$this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, datetime DATETIME NOT NULL, level TINYINT(4) NOT NULL, target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL --(DC2Type:json)
, type SMALLINT NOT NULL, username VARCHAR(255) NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO log (id, id_user, datetime, level, target_id, target_type, extra, type, username) SELECT id, id_user, datetime, level, target_id, target_type, extra, type, username FROM __temp__log');
$this->addSql('DROP TABLE __temp__log');
$this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)');
$this->addSql('CREATE INDEX log_idx_type ON log (type)');
$this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)');
$this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM parts');
$this->addSql('DROP TABLE parts');
$this->addSql('CREATE TABLE parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachement INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, built_project_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, ipn VARCHAR(100) DEFAULT NULL, CONSTRAINT FK_6940A7FE6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO parts (id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn) SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order, ipn FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE INDEX parts_idx_ipn ON parts (ipn)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn)');
$this->addSql('CREATE INDEX parts_idx_name ON parts (name)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE INDEX IDX_6940A7FE6DEDCEC2 ON parts (id_preview_attachement)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM pricedetails');
$this->addSql('DROP TABLE pricedetails');
$this->addSql('CREATE TABLE pricedetails (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL --(DC2Type:big_decimal)
, price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO pricedetails (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails');
$this->addSql('DROP TABLE __temp__pricedetails');
$this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON pricedetails (min_discount_quantity, price_related_quantity)');
$this->addSql('CREATE INDEX pricedetails_idx_min_discount ON pricedetails (min_discount_quantity)');
$this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON pricedetails (id_currency)');
$this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON pricedetails (orderdetails_id)');
$this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, quantity, mountnames FROM project_bom_entries');
$this->addSql('DROP TABLE project_bom_entries');
$this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, price_currency_id INTEGER DEFAULT NULL, quantity DOUBLE PRECISION NOT NULL, mountnames CLOB NOT NULL, name VARCHAR(255) DEFAULT NULL, comment CLOB NOT NULL, price NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal)
, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AFC547992F180363 FOREIGN KEY (id_device) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AFC54799C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD313FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, quantity, mountnames) SELECT id, id_device, id_part, quantity, mountnames FROM __temp__project_bom_entries');
$this->addSql('DROP TABLE __temp__project_bom_entries');
$this->addSql('CREATE INDEX IDX_1AA2DD313FFDCD60 ON project_bom_entries (price_currency_id)');
$this->addSql('CREATE INDEX IDX_1AA2DD312F180363 ON project_bom_entries (id_device)');
$this->addSql('CREATE INDEX IDX_1AA2DD31C22F6CC4 ON project_bom_entries (id_part)');
$this->addSql('CREATE TEMPORARY TABLE __temp__projects AS SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM projects');
$this->addSql('DROP TABLE projects');
$this->addSql('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, order_quantity INTEGER NOT NULL, order_only_missing_parts BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, status VARCHAR(64) DEFAULT NULL, description CLOB NOT NULL DEFAULT "", CONSTRAINT FK_11074E9A727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_11074E9A6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO projects (id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM __temp__projects');
$this->addSql('DROP TABLE __temp__projects');
$this->addSql('CREATE INDEX IDX_5C93B3A4727ACA70 ON projects (parent_id)');
$this->addSql('CREATE INDEX IDX_5C93B3A46DEDCEC2 ON projects (id_preview_attachement)');
$this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM suppliers');
$this->addSql('DROP TABLE suppliers');
$this->addSql('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL --(DC2Type:big_decimal)
, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES suppliers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95C6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO suppliers (id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers');
$this->addSql('DROP TABLE __temp__suppliers');
$this->addSql('CREATE INDEX supplier_idx_parent_name ON suppliers (parent_id, name)');
$this->addSql('CREATE INDEX supplier_idx_name ON suppliers (name)');
$this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON suppliers (parent_id)');
$this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON suppliers (default_currency_id)');
$this->addSql('CREATE INDEX IDX_AC28B95C6DEDCEC2 ON suppliers (id_preview_attachement)');
$this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM users');
$this->addSql('DROP TABLE users');
$this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL --(DC2Type:json)
, google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL --(DC2Type:json)
, backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --(DC2Type:json)
, CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES groups (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E96DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO users (id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data) SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM __temp__users');
$this->addSql('DROP TABLE __temp__users');
$this->addSql('CREATE INDEX IDX_1483A5E96DEDCEC2 ON users (id_preview_attachement)');
$this->addSql('CREATE INDEX IDX_1483A5E938248176 ON users (currency_id)');
$this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON users (group_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON users (name)');
$this->addSql('CREATE INDEX user_idx_username ON users (name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM webauthn_keys');
$this->addSql('DROP TABLE webauthn_keys');
$this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL --(DC2Type:base64)
, type VARCHAR(255) NOT NULL, transports CLOB NOT NULL --(DC2Type:array)
, attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL --(DC2Type:trust_path)
, aaguid CLOB NOT NULL --(DC2Type:aaguid)
, credential_public_key CLOB NOT NULL --(DC2Type:base64)
, user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM __temp__webauthn_keys');
$this->addSql('DROP TABLE __temp__webauthn_keys');
$this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)');
}
public function sqLiteDown(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TEMPORARY TABLE __temp__currencies AS SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM currencies');
$this->addSql('DROP TABLE currencies');
$this->addSql('CREATE TABLE currencies (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, exchange_rate NUMERIC(11, 5) DEFAULT NULL --
(DC2Type:big_decimal)
, iso_code VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_37C44693727ACA70 FOREIGN KEY (parent_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_37C446936DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO currencies (id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, exchange_rate, iso_code, comment, not_selectable, name, last_modified, datetime_added FROM __temp__currencies');
$this->addSql('DROP TABLE __temp__currencies');
$this->addSql('CREATE INDEX IDX_37C44693727ACA70 ON currencies (parent_id)');
$this->addSql('CREATE INDEX IDX_37C446936DEDCEC2 ON currencies (id_preview_attachement)');
$this->addSql('CREATE INDEX currency_idx_name ON currencies (name)');
$this->addSql('CREATE INDEX currency_idx_parent_name ON currencies (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__groups AS SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM "groups"');
$this->addSql('DROP TABLE "groups"');
$this->addSql('CREATE TABLE "groups" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, enforce_2fa BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --
(DC2Type:json)
, CONSTRAINT FK_F06D3970727ACA70 FOREIGN KEY (parent_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_F06D39706DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "groups" (id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data) SELECT id, parent_id, id_preview_attachement, enforce_2fa, comment, not_selectable, name, last_modified, datetime_added, permissions_data FROM __temp__groups');
$this->addSql('DROP TABLE __temp__groups');
$this->addSql('CREATE INDEX IDX_F06D3970727ACA70 ON "groups" (parent_id)');
$this->addSql('CREATE INDEX IDX_F06D39706DEDCEC2 ON "groups" (id_preview_attachement)');
$this->addSql('CREATE INDEX group_idx_name ON "groups" (name)');
$this->addSql('CREATE INDEX group_idx_parent_name ON "groups" (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__log AS SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM log');
$this->addSql('DROP TABLE log');
$this->addSql('CREATE TABLE log (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_user INTEGER DEFAULT NULL, username VARCHAR(255) DEFAULT \'""\' NOT NULL, datetime DATETIME NOT NULL, level BOOLEAN NOT NULL, target_id INTEGER NOT NULL, target_type SMALLINT NOT NULL, extra CLOB NOT NULL --(DC2Type:json)
, type SMALLINT NOT NULL, CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO log (id, id_user, username, datetime, level, target_id, target_type, extra, type) SELECT id, id_user, username, datetime, level, target_id, target_type, extra, type FROM __temp__log');
$this->addSql('DROP TABLE __temp__log');
$this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)');
$this->addSql('CREATE INDEX log_idx_type ON log (type)');
$this->addSql('CREATE INDEX log_idx_type_target ON log (type, target_type, target_id)');
$this->addSql('CREATE INDEX log_idx_datetime ON log (datetime)');
$this->addSql('CREATE TEMPORARY TABLE __temp__parts AS SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM "parts"');
$this->addSql('DROP TABLE "parts"');
$this->addSql('CREATE TABLE "parts" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_preview_attachement INTEGER DEFAULT NULL, id_category INTEGER NOT NULL, id_footprint INTEGER DEFAULT NULL, id_part_unit INTEGER DEFAULT NULL, id_manufacturer INTEGER DEFAULT NULL, order_orderdetails_id INTEGER DEFAULT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, needs_review BOOLEAN NOT NULL, tags CLOB NOT NULL, mass DOUBLE PRECISION DEFAULT NULL, ipn VARCHAR(100) DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, visible BOOLEAN NOT NULL, favorite BOOLEAN NOT NULL, minamount DOUBLE PRECISION NOT NULL, manufacturer_product_url VARCHAR(255) NOT NULL, manufacturer_product_number VARCHAR(255) NOT NULL, manufacturing_status VARCHAR(255) DEFAULT NULL, order_quantity INTEGER NOT NULL, manual_order BOOLEAN NOT NULL, CONSTRAINT FK_6940A7FE6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "parts" (id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order) SELECT id, id_preview_attachement, id_category, id_footprint, id_part_unit, id_manufacturer, order_orderdetails_id, datetime_added, name, last_modified, needs_review, tags, mass, ipn, description, comment, visible, favorite, minamount, manufacturer_product_url, manufacturer_product_number, manufacturing_status, order_quantity, manual_order FROM __temp__parts');
$this->addSql('DROP TABLE __temp__parts');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn)');
$this->addSql('CREATE INDEX IDX_6940A7FE6DEDCEC2 ON "parts" (id_preview_attachement)');
$this->addSql('CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category)');
$this->addSql('CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint)');
$this->addSql('CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit)');
$this->addSql('CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id)');
$this->addSql('CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review)');
$this->addSql('CREATE INDEX parts_idx_name ON "parts" (name)');
$this->addSql('CREATE INDEX parts_idx_ipn ON "parts" (ipn)');
$this->addSql('CREATE TEMPORARY TABLE __temp__pricedetails AS SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM "pricedetails"');
$this->addSql('DROP TABLE "pricedetails"');
$this->addSql('CREATE TABLE "pricedetails" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_currency INTEGER DEFAULT NULL, orderdetails_id INTEGER NOT NULL, price NUMERIC(11, 5) NOT NULL --
(DC2Type:big_decimal)
, price_related_quantity DOUBLE PRECISION NOT NULL, min_discount_quantity DOUBLE PRECISION NOT NULL, manual_input BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_C68C4459398D64AA FOREIGN KEY (id_currency) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_C68C44594A01DDC7 FOREIGN KEY (orderdetails_id) REFERENCES "orderdetails" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "pricedetails" (id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added) SELECT id, id_currency, orderdetails_id, price, price_related_quantity, min_discount_quantity, manual_input, last_modified, datetime_added FROM __temp__pricedetails');
$this->addSql('DROP TABLE __temp__pricedetails');
$this->addSql('CREATE INDEX IDX_C68C4459398D64AA ON "pricedetails" (id_currency)');
$this->addSql('CREATE INDEX IDX_C68C44594A01DDC7 ON "pricedetails" (orderdetails_id)');
$this->addSql('CREATE INDEX pricedetails_idx_min_discount ON "pricedetails" (min_discount_quantity)');
$this->addSql('CREATE INDEX pricedetails_idx_min_discount_price_qty ON "pricedetails" (min_discount_quantity, price_related_quantity)');
$this->addSql('CREATE TEMPORARY TABLE __temp__project_bom_entries AS SELECT id, id_device, id_part, quantity, mountnames FROM project_bom_entries');
$this->addSql('DROP TABLE project_bom_entries');
$this->addSql('CREATE TABLE project_bom_entries (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_device INTEGER DEFAULT NULL, id_part INTEGER DEFAULT NULL, quantity INTEGER NOT NULL, mountnames CLOB NOT NULL, CONSTRAINT FK_1AA2DD312F180363 FOREIGN KEY (id_device) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1AA2DD31C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO project_bom_entries (id, id_device, id_part, quantity, mountnames) SELECT id, id_device, id_part, quantity, mountnames FROM __temp__project_bom_entries');
$this->addSql('DROP TABLE __temp__project_bom_entries');
$this->addSql('CREATE INDEX IDX_AFC547992F180363 ON project_bom_entries (id_device)');
$this->addSql('CREATE INDEX IDX_AFC54799C22F6CC4 ON project_bom_entries (id_part)');
$this->addSql('CREATE TEMPORARY TABLE __temp__projects AS SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM projects');
$this->addSql('DROP TABLE projects');
$this->addSql('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, order_quantity INTEGER NOT NULL, order_only_missing_parts BOOLEAN NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_5C93B3A4727ACA70 FOREIGN KEY (parent_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_5C93B3A46DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO projects (id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, id_preview_attachement, order_quantity, order_only_missing_parts, comment, not_selectable, name, last_modified, datetime_added FROM __temp__projects');
$this->addSql('DROP TABLE __temp__projects');
$this->addSql('CREATE INDEX IDX_11074E9A727ACA70 ON projects (parent_id)');
$this->addSql('CREATE INDEX IDX_11074E9A6DEDCEC2 ON projects (id_preview_attachement)');
$this->addSql('CREATE TEMPORARY TABLE __temp__suppliers AS SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM "suppliers"');
$this->addSql('DROP TABLE "suppliers"');
$this->addSql('CREATE TABLE "suppliers" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, parent_id INTEGER DEFAULT NULL, default_currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, shipping_costs NUMERIC(11, 5) DEFAULT NULL --
(DC2Type:big_decimal)
, address VARCHAR(255) NOT NULL, phone_number VARCHAR(255) NOT NULL, fax_number VARCHAR(255) NOT NULL, email_address VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, auto_product_url VARCHAR(255) NOT NULL, comment CLOB NOT NULL, not_selectable BOOLEAN NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_AC28B95C727ACA70 FOREIGN KEY (parent_id) REFERENCES "suppliers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95CECD792C0 FOREIGN KEY (default_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_AC28B95C6DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "suppliers" (id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added) SELECT id, parent_id, default_currency_id, id_preview_attachement, shipping_costs, address, phone_number, fax_number, email_address, website, auto_product_url, comment, not_selectable, name, last_modified, datetime_added FROM __temp__suppliers');
$this->addSql('DROP TABLE __temp__suppliers');
$this->addSql('CREATE INDEX IDX_AC28B95C727ACA70 ON "suppliers" (parent_id)');
$this->addSql('CREATE INDEX IDX_AC28B95CECD792C0 ON "suppliers" (default_currency_id)');
$this->addSql('CREATE INDEX IDX_AC28B95C6DEDCEC2 ON "suppliers" (id_preview_attachement)');
$this->addSql('CREATE INDEX supplier_idx_name ON "suppliers" (name)');
$this->addSql('CREATE INDEX supplier_idx_parent_name ON "suppliers" (parent_id, name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__users AS SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM "users"');
$this->addSql('DROP TABLE "users"');
$this->addSql('CREATE TABLE "users" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, group_id INTEGER DEFAULT NULL, currency_id INTEGER DEFAULT NULL, id_preview_attachement INTEGER DEFAULT NULL, disabled BOOLEAN NOT NULL, config_theme VARCHAR(255) DEFAULT NULL, pw_reset_token VARCHAR(255) DEFAULT NULL, config_instock_comment_a CLOB NOT NULL, config_instock_comment_w CLOB NOT NULL, trusted_device_cookie_version INTEGER NOT NULL, backup_codes CLOB NOT NULL --
(DC2Type:json)
, google_authenticator_secret VARCHAR(255) DEFAULT NULL, config_timezone VARCHAR(255) DEFAULT NULL, config_language VARCHAR(255) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, department VARCHAR(255) DEFAULT NULL, last_name VARCHAR(255) DEFAULT NULL, first_name VARCHAR(255) DEFAULT NULL, need_pw_change BOOLEAN NOT NULL, password VARCHAR(255) DEFAULT NULL, name VARCHAR(180) NOT NULL, settings CLOB NOT NULL --
(DC2Type:json)
, backup_codes_generation_date DATETIME DEFAULT NULL, pw_reset_expires DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, permissions_data CLOB DEFAULT \'[]\' NOT NULL --
(DC2Type:json)
, CONSTRAINT FK_1483A5E9FE54D947 FOREIGN KEY (group_id) REFERENCES "groups" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E938248176 FOREIGN KEY (currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_1483A5E96DEDCEC2 FOREIGN KEY (id_preview_attachement) REFERENCES "attachments" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO "users" (id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data) SELECT id, group_id, currency_id, id_preview_attachement, disabled, config_theme, pw_reset_token, config_instock_comment_a, config_instock_comment_w, trusted_device_cookie_version, backup_codes, google_authenticator_secret, config_timezone, config_language, email, department, last_name, first_name, need_pw_change, password, name, settings, backup_codes_generation_date, pw_reset_expires, last_modified, datetime_added, permissions_data FROM __temp__users');
$this->addSql('DROP TABLE __temp__users');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E95E237E06 ON "users" (name)');
$this->addSql('CREATE INDEX IDX_1483A5E9FE54D947 ON "users" (group_id)');
$this->addSql('CREATE INDEX IDX_1483A5E938248176 ON "users" (currency_id)');
$this->addSql('CREATE INDEX IDX_1483A5E96DEDCEC2 ON "users" (id_preview_attachement)');
$this->addSql('CREATE INDEX user_idx_username ON "users" (name)');
$this->addSql('CREATE TEMPORARY TABLE __temp__webauthn_keys AS SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM webauthn_keys');
$this->addSql('DROP TABLE webauthn_keys');
$this->addSql('CREATE TABLE webauthn_keys (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, public_key_credential_id CLOB NOT NULL --
(DC2Type:base64)
, type VARCHAR(255) NOT NULL, transports CLOB NOT NULL --
(DC2Type:array)
, attestation_type VARCHAR(255) NOT NULL, trust_path CLOB NOT NULL --
(DC2Type:trust_path)
, aaguid CLOB NOT NULL --
(DC2Type:aaguid)
, credential_public_key CLOB NOT NULL --
(DC2Type:base64)
, user_handle VARCHAR(255) NOT NULL, counter INTEGER NOT NULL, name VARCHAR(255) NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_799FD143A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO webauthn_keys (id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added) SELECT id, user_id, public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, name, last_modified, datetime_added FROM __temp__webauthn_keys');
$this->addSql('DROP TABLE __temp__webauthn_keys');
$this->addSql('CREATE INDEX IDX_799FD143A76ED395 ON webauthn_keys (user_id)');
$this->addSql('ALTER TABLE projects RENAME TO devices');
$this->addSql('ALTER TABLE project_bom_entries RENAME TO device_parts');
}
}

View file

@ -24,7 +24,7 @@ namespace App\Command\Migrations;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
@ -94,7 +94,7 @@ class ConvertBBCodeCommand extends Command
Part::class => ['description', 'comment'],
AttachmentType::class => ['comment'],
Storelocation::class => ['comment'],
Device::class => ['comment'],
Project::class => ['comment'],
Category::class => ['comment'],
Manufacturer::class => ['comment'],
MeasurementUnit::class => ['comment'],

View file

@ -0,0 +1,127 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Command\User;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\PermissionData;
use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\UserSystem\PermissionSchemaUpdater;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
final class UpgradePermissionsSchemaCommand extends Command
{
protected static $defaultName = 'partdb:users:upgrade-permissions-schema';
protected static $defaultDescription = '(Manually) upgrades the permissions schema of all users to the latest version.';
private PermissionSchemaUpdater $permissionSchemaUpdater;
private EntityManagerInterface $em;
private EventCommentHelper $eventCommentHelper;
public function __construct(PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager, EventCommentHelper $eventCommentHelper)
{
parent::__construct(self::$defaultName);
$this->permissionSchemaUpdater = $permissionSchemaUpdater;
$this->eventCommentHelper = $eventCommentHelper;
$this->em = $entityManager;
}
protected function configure(): void
{
$this
->setDescription(self::$defaultDescription)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->info('Target schema version number: '. PermissionData::CURRENT_SCHEMA_VERSION);
//Retrieve all users and groups
$users = $this->em->getRepository(User::class)->findAll();
$groups = $this->em->getRepository(Group::class)->findAll();
//Check which users and groups need an update
$groups_to_upgrade = [];
$users_to_upgrade = [];
foreach ($groups as $group) {
if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($group)) {
$groups_to_upgrade[] = $group;
}
}
foreach ($users as $user) {
if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) {
$users_to_upgrade[] = $user;
}
}
$io->info('Found '. count($groups_to_upgrade) .' groups and '. count($users_to_upgrade) .' users that need an update.');
if (empty($groups_to_upgrade) && empty($users_to_upgrade)) {
$io->success('All users and group permissions schemas are up-to-date. No update needed.');
return 0;
}
//List all users and groups that need an update
$io->section('Groups that need an update:');
$io->listing(array_map(function (Group $group) {
return $group->getName() . ' (ID: '. $group->getID() .', Current version: ' . $group->getPermissions()->getSchemaVersion() . ')';
}, $groups_to_upgrade));
$io->section('Users that need an update:');
$io->listing(array_map(function (User $user) {
return $user->getUsername() . ' (ID: '. $user->getID() .', Current version: ' . $user->getPermissions()->getSchemaVersion() . ')';
}, $users_to_upgrade));
if(!$io->confirm('Continue with the update?', false)) {
$io->warning('Update aborted.');
return 0;
}
//Update all users and groups
foreach ($groups_to_upgrade as $group) {
$io->writeln('Updating group '. $group->getName() .' (ID: '. $group->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE);
$this->permissionSchemaUpdater->upgradeSchema($group);
}
foreach ($users_to_upgrade as $user) {
$io->writeln('Updating user '. $user->getUsername() .' (ID: '. $user->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE);
$this->permissionSchemaUpdater->upgradeSchema($user);
}
$this->eventCommentHelper->setMessage('Manual permissions schema update via CLI');
//Write changes to database
$this->em->flush();
$io->success('All users and groups have been updated to the latest permissions schema version.');
return Command::SUCCESS;
}
}

View file

@ -22,10 +22,11 @@ declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Devices\Device;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\ProjectParameter;
use App\Form\AdminPages\BaseEntityAdminForm;
use App\Form\AdminPages\ProjectAdminForm;
use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
use App\Services\Trees\StructuralElementRecursionHelper;
@ -36,46 +37,46 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/device")
* @Route("/project")
*/
class DeviceController extends BaseAdminController
class ProjectAdminController extends BaseAdminController
{
protected $entity_class = Device::class;
protected $twig_template = 'AdminPages/DeviceAdmin.html.twig';
protected $form_class = BaseEntityAdminForm::class;
protected $route_base = 'device';
protected $attachment_class = DeviceAttachment::class;
protected $parameter_class = DeviceParameter::class;
protected $entity_class = Project::class;
protected $twig_template = 'AdminPages/ProjectAdmin.html.twig';
protected $form_class = ProjectAdminForm::class;
protected $route_base = 'project';
protected $attachment_class = ProjectAttachment::class;
protected $parameter_class = ProjectParameter::class;
/**
* @Route("/{id}", name="device_delete", methods={"DELETE"})
* @Route("/{id}", name="project_delete", methods={"DELETE"})
*/
public function delete(Request $request, Device $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
public function delete(Request $request, Project $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
{
return $this->_delete($request, $entity, $recursionHelper);
}
/**
* @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="device_edit")
* @Route("/{id}", requirements={"id"="\d+"})
* @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="project_edit")
* @Route("/{id}/edit", requirements={"id"="\d+"})
*/
public function edit(Device $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
public function edit(Project $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
{
return $this->_edit($entity, $request, $em, $timestamp);
}
/**
* @Route("/new", name="device_new")
* @Route("/new", name="project_new")
* @Route("/{id}/clone", name="device_clone")
* @Route("/")
*/
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Device $entity = null): Response
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Project $entity = null): Response
{
return $this->_new($request, $em, $importer, $entity);
}
/**
* @Route("/export", name="device_export_all")
* @Route("/export", name="project_export_all")
*/
public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response
{
@ -83,9 +84,9 @@ class DeviceController extends BaseAdminController
}
/**
* @Route("/{id}/export", name="device_export")
* @Route("/{id}/export", name="project_export")
*/
public function exportEntity(Device $entity, EntityExporter $exporter, Request $request): Response
public function exportEntity(Project $entity, EntityExporter $exporter, Request $request): Response
{
return $this->_exportEntity($entity, $exporter, $request);
}

View file

@ -32,6 +32,7 @@ use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
use App\Services\Trees\StructuralElementRecursionHelper;
use App\Services\UserSystem\PermissionPresetsHelper;
use App\Services\UserSystem\PermissionSchemaUpdater;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@ -54,8 +55,11 @@ class GroupController extends BaseAdminController
* @Route("/{id}/edit/{timestamp}", requirements={"id"="\d+"}, name="group_edit")
* @Route("/{id}/", requirements={"id"="\d+"})
*/
public function edit(Group $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, ?string $timestamp = null): Response
public function edit(Group $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, PermissionSchemaUpdater $permissionSchemaUpdater, ?string $timestamp = null): Response
{
//Do an upgrade of the permission schema if needed (so the user can see the permissions a user get on next request (even if it was not done yet)
$permissionSchemaUpdater->groupUpgradeSchemaRecursively($entity);
//Handle permissions presets
if ($request->request->has('permission_preset')) {
$this->denyAccessUnlessGranted('edit_permissions', $entity);

View file

@ -31,6 +31,7 @@ use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project;
use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentSubmitHandler;
@ -39,16 +40,20 @@ use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\HistoryHelper;
use App\Services\LogSystem\TimeTravel;
use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Omines\DataTablesBundle\DataTableFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -76,7 +81,7 @@ class PartController extends AbstractController
* @throws Exception
*/
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, ?string $timestamp = null): Response
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
{
$this->denyAccessUnlessGranted('read', $part);
@ -117,6 +122,7 @@ class PartController extends AbstractController
'timeTravel' => $timeTravel_timestamp,
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
'withdraw_add_helper' => $withdrawAddHelper,
]
);
}
@ -203,14 +209,26 @@ class PartController extends AbstractController
/**
* @Route("/new", name="part_new")
* @Route("/{id}/clone", name="part_clone")
* @Route("/new_build_part/{project_id}", name="part_new_build_part")
* @ParamConverter("part", options={"id" = "id"})
* @ParamConverter("project", options={"id" = "project_id"})
*/
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
AttachmentSubmitHandler $attachmentSubmitHandler, ?Part $part = null): Response
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
?Part $part = null, ?Project $project = null): Response
{
if (null === $part) {
$new_part = new Part();
} else {
if ($part) { //Clone part
$new_part = clone $part;
} else if ($project) { //Initialize a new part for a build part from the given project
//Ensure that the project has not already a build part
if ($project->getBuildPart() !== null) {
$this->addFlash('error', 'part.new_build_part.error.build_part_already_exists');
return $this->redirectToRoute('part_edit', ['id' => $project->getBuildPart()->getID()]);
}
$new_part = $projectBuildPartHelper->getPartInitialization($project);
} else { //Create an empty part from scratch
$new_part = new Part();
}
$this->denyAccessUnlessGranted('create', $new_part);
@ -280,6 +298,11 @@ class PartController extends AbstractController
$em->flush();
$this->addFlash('success', 'part.created_flash');
//If a redirect URL was given, redirect there
if ($request->query->get('_redirect')) {
return $this->redirect($request->query->get('_redirect'));
}
//Redirect to clone page if user wished that...
//@phpstan-ignore-next-line
if ('save_and_clone' === $form->getClickedButton()->getName()) {
@ -299,4 +322,66 @@ class PartController extends AbstractController
'form' => $form,
]);
}
/**
* @Route("/{id}/add_withdraw", name="part_add_withdraw", methods={"POST"})
*/
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{
if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) {
//Retrieve partlot from the request
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
if($partLot === null) {
throw new \RuntimeException('Part lot not found!');
}
//Ensure that the partlot belongs to the part
if($partLot->getPart() !== $part) {
throw new \RuntimeException("The origin partlot does not belong to the part!");
}
//Try to determine the target lot (used for move actions)
$targetLot = $em->find(PartLot::class, $request->request->get('target_id'));
if ($targetLot && $targetLot->getPart() !== $part) {
throw new \RuntimeException("The target partlot does not belong to the part!");
}
//Extract the amount and comment from the request
$amount = (float) $request->request->get('amount');
$comment = $request->request->get('comment');
$action = $request->request->get('action');
switch ($action) {
case "withdraw":
case "remove":
$this->denyAccessUnlessGranted('withdraw', $partLot);
$withdrawAddHelper->withdraw($partLot, $amount, $comment);
break;
case "add":
$this->denyAccessUnlessGranted('add', $partLot);
$withdrawAddHelper->add($partLot, $amount, $comment);
break;
case "move":
$this->denyAccessUnlessGranted('move', $partLot);
$withdrawAddHelper->move($partLot, $targetLot, $amount, $comment);
break;
default:
throw new \RuntimeException("Unknown action!");
}
//Save the changes to the DB
$em->flush();
$this->addFlash('success', 'part.withdraw.success');
} else {
$this->addFlash('error', 'CSRF Token invalid!');
}
//If an redirect was passed, then redirect there
if($request->request->get('_redirect')) {
return $this->redirect($request->request->get('_redirect'));
}
//Otherwise just redirect to the part page
return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
}
}

View file

@ -77,7 +77,7 @@ class PartListsController extends AbstractController
$this->addFlash('error', 'part.table.actions.no_params_given');
} else {
$parts = $actionHandler->idStringToArray($ids);
$actionHandler->handleAction($action, $parts, $target ? (int) $target : null);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
//Save changes
$this->entityManager->flush();
@ -85,6 +85,11 @@ class PartListsController extends AbstractController
$this->addFlash('success', 'part.table.actions.success');
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
if (isset($redirectResponse) && $redirectResponse instanceof Response) {
return $redirectResponse;
}
return $this->redirect($redirect);
}

View file

@ -0,0 +1,149 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Controller;
use App\DataTables\ProjectBomEntriesDataTable;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Form\ProjectSystem\ProjectBOMEntryCollectionType;
use App\Form\Type\StructuralEntityType;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints\NotNull;
/**
* @Route("/project")
*/
class ProjectController extends AbstractController
{
private DataTableFactory $dataTableFactory;
public function __construct(DataTableFactory $dataTableFactory)
{
$this->dataTableFactory = $dataTableFactory;
}
/**
* @Route("/{id}/info", name="project_info", requirements={"id"="\d+"})
*/
public function info(Project $project, Request $request)
{
$this->denyAccessUnlessGranted('read', $project);
$table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('Projects/info/info.html.twig', [
'datatable' => $table,
'project' => $project,
]);
}
/**
* @Route("/add_parts", name="project_add_parts_no_id")
* @Route("/{id}/add_parts", name="project_add_parts", requirements={"id"="\d+"})
* @param Request $request
* @param Project|null $project
*/
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
{
if($project) {
$this->denyAccessUnlessGranted('edit', $project);
} else {
$this->denyAccessUnlessGranted('@projects.edit');
}
$builder = $this->createFormBuilder();
$builder->add('project', StructuralEntityType::class, [
'class' => Project::class,
'required' => true,
'disabled' => $project !== null, //If a project is given, disable the field
'data' => $project,
'constraints' => [
new NotNull()
]
]);
$builder->add('bom_entries', ProjectBOMEntryCollectionType::class);
$builder->add('submit', SubmitType::class, ['label' => 'save']);
$form = $builder->getForm();
//Preset the BOM entries with the selected parts, when the form was not submitted yet
$preset_data = new ArrayCollection();
foreach (explode(',', $request->get('parts', '')) as $part_id) {
$part = $entityManager->getRepository(Part::class)->find($part_id);
if (null !== $part) {
//If there is already a BOM entry for this part, we use this one (we edit it then)
$bom_entry = $entityManager->getRepository(ProjectBOMEntry::class)->findOneBy([
'project' => $project,
'part' => $part
]);
if ($bom_entry) {
$preset_data->add($bom_entry);
} else { //Otherwise create an empty one
$entry = new ProjectBOMEntry();
$entry->setProject($project);
$entry->setPart($part);
$preset_data->add($entry);
}
}
}
$form['bom_entries']->setData($preset_data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$target_project = $project ?? $form->get('project')->getData();
//Ensure that we really have acces to the selected project
$this->denyAccessUnlessGranted('edit', $target_project);
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
$target_project->addBOMEntry($bom_entry);
}
$entityManager->flush();
//If a redirect query parameter is set, redirect to this page
if ($request->query->get('_redirect')) {
return $this->redirect($request->query->get('_redirect'));
}
//Otherwise just show the project info page
return $this->redirectToRoute('project_info', ['id' => $target_project->getID()]);
}
return $this->renderForm('Projects/add_parts.html.twig', [
'project' => $project,
'form' => $form,
]);
}
}

View file

@ -25,6 +25,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\ProjectSystem\Project;
use App\Services\Trees\NodesListBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@ -79,6 +80,14 @@ class SelectAPIController extends AbstractController
return $this->getResponseForClass(MeasurementUnit::class, true);
}
/**
* @Route("/project", name="select_project")
*/
public function projects(): Response
{
return $this->getResponseForClass(Project::class, false);
}
protected function getResponseForClass(string $class, bool $include_empty = false): Response
{
$test_obj = new $class();

View file

@ -22,7 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -136,10 +136,10 @@ class TreeController extends AbstractController
* @Route("/device/{id}", name="tree_device")
* @Route("/devices", name="tree_device_root")
*/
public function deviceTree(?Device $device = null): JsonResponse
public function deviceTree(?Project $device = null): JsonResponse
{
if ($this->isGranted('@devices.read')) {
$tree = $this->treeGenerator->getTreeView(Device::class, $device, 'devices');
if ($this->isGranted('@projects.read')) {
$tree = $this->treeGenerator->getTreeView(Project::class, $device, 'devices');
} else {
return new JsonResponse("Access denied", 403);
}

View file

@ -24,7 +24,7 @@ namespace App\Controller;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
@ -32,10 +32,12 @@ use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Repository\ParameterRepository;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\Tools\TagFinder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -105,7 +107,7 @@ class TypeaheadController extends AbstractController
case 'part':
return PartParameter::class;
case 'device':
return DeviceParameter::class;
return ProjectParameter::class;
case 'footprint':
return FootprintParameter::class;
case 'manufacturer':
@ -128,6 +130,45 @@ class TypeaheadController extends AbstractController
}
}
/**
* @Route("/parts/search/{query}", name="typeahead_parts")
* @param string $query
* @param EntityManagerInterface $entityManager
* @return JsonResponse
*/
public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse
{
$this->denyAccessUnlessGranted('@parts.read');
$repo = $entityManager->getRepository(Part::class);
$parts = $repo->autocompleteSearch($query, 100);
$data = [];
foreach ($parts as $part) {
//Determine the picture to show:
$preview_attachment = $previewGenerator->getTablePreviewAttachment($part);
if($preview_attachment !== null) {
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
} else {
$preview_url = '';
}
/** @var Part $part */
$data[] = [
'id' => $part->getID(),
'name' => $part->getName(),
'category' => $part->getCategory() ? $part->getCategory()->getName() : 'Unknown',
'footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
}
return new JsonResponse($data);
}
/**
* @Route("/parameters/{type}/search/{query}", name="typeahead_parameters", requirements={"type" = ".+"})
* @param string $query

View file

@ -35,6 +35,7 @@ use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
use App\Services\Trees\StructuralElementRecursionHelper;
use App\Services\UserSystem\PermissionPresetsHelper;
use App\Services\UserSystem\PermissionSchemaUpdater;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use InvalidArgumentException;
@ -82,10 +83,12 @@ class UserController extends AdminPages\BaseAdminController
*
* @throws Exception
*/
public function edit(User $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, ?string $timestamp = null): Response
public function edit(User $entity, Request $request, EntityManagerInterface $em, PermissionPresetsHelper $permissionPresetsHelper, PermissionSchemaUpdater $permissionSchemaUpdater, ?string $timestamp = null): Response
{
//Handle 2FA disabling
//Do an upgrade of the permission schema if needed (so the user can see the permissions a user get on next request (even if it was not done yet)
$permissionSchemaUpdater->userUpgradeSchemaRecursively($entity);
//Handle 2FA disabling
if ($request->request->has('reset_2fa')) {
//Check if the admin has the needed permissions
$this->denyAccessUnlessGranted('set_password', $entity);

View file

@ -24,7 +24,7 @@ namespace App\DataFixtures;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -51,7 +51,7 @@ class DataStructureFixtures extends Fixture
public function load(ObjectManager $manager): void
{
//Reset autoincrement
$types = [AttachmentType::class, Device::class, Category::class, Footprint::class, Manufacturer::class,
$types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class,
MeasurementUnit::class, Storelocation::class, Supplier::class, ];
foreach ($types as $type) {

View file

@ -72,7 +72,7 @@ class GroupFixtures extends Fixture
private function addDevicesPermissions(Group $group): void
{
$this->permissionManager->setAllOperationsOfPermission($group, 'devices', true);
$this->permissionManager->setAllOperationsOfPermission($group, 'projects', true);
}
}

View file

@ -65,16 +65,21 @@ class EntityColumn extends AbstractColumn
});
$resolver->setDefault('render', function (Options $options) {
return function ($value, Part $context) use ($options) {
/** @var AbstractDBElement|null $entity */
$entity = $this->accessor->getValue($context, $options['property']);
return function ($value, $context) use ($options) {
if ($this->accessor->isReadable($context, $options['property'])) {
$entity = $this->accessor->getValue($context, $options['property']);
} else {
$entity = null;
}
/** @var AbstractNamedDBElement|null $entity */
if (null !== $entity) {
if (null !== $entity->getID()) {
return sprintf(
'<a href="%s">%s</a>',
$this->urlGenerator->listPartsURL($entity),
$value
$entity->getName()
);
}

View file

@ -31,6 +31,7 @@ use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\PartLot;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Exceptions\EntityNotSupportedException;
use App\Repository\LogEntryRepository;
use App\Services\ElementTypeNameGenerator;
@ -126,6 +127,8 @@ class LogEntryTargetColumn extends AbstractColumn
$on = $target->getPart();
} elseif ($target instanceof Pricedetail && null !== $target->getOrderdetail() && null !== $target->getOrderdetail()->getPart()) {
$on = $target->getOrderdetail()->getPart();
} elseif ($target instanceof ProjectBOMEntry && null !== $target->getProject()) {
$on = $target->getProject();
}
if (isset($on) && is_object($on)) {

View file

@ -0,0 +1,95 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Helpers;
use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityURLGenerator;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* A helper service which contains common code to render columns for part related tables
*/
class PartDataTableHelper
{
private PartPreviewGenerator $previewGenerator;
private AttachmentURLGenerator $attachmentURLGenerator;
private TranslatorInterface $translator;
private EntityURLGenerator $entityURLGenerator;
public function __construct(PartPreviewGenerator $previewGenerator, AttachmentURLGenerator $attachmentURLGenerator,
EntityURLGenerator $entityURLGenerator, TranslatorInterface $translator)
{
$this->previewGenerator = $previewGenerator;
$this->attachmentURLGenerator = $attachmentURLGenerator;
$this->translator = $translator;
$this->entityURLGenerator = $entityURLGenerator;
}
public function renderName(Part $context): string
{
$icon = '';
//Depending on the part status we show a different icon (the later conditions have higher priority)
if ($context->isFavorite()) {
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>', $this->translator->trans('part.favorite.badge'));
}
if ($context->isNeedsReview()) {
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>', $this->translator->trans('part.needs_review.badge'));
}
if ($context->getBuiltProject() !== null) {
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
$this->translator->trans('part.info.projectBuildPart.hint') . ': ' . $context->getBuiltProject()->getName());
}
return sprintf(
'<a href="%s">%s%s</a>',
$this->entityURLGenerator->infoURL($context),
$icon,
htmlentities($context->getName())
);
}
public function renderPicture(Part $context): string
{
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
if (null === $preview_attachment) {
return '';
}
$title = htmlspecialchars($preview_attachment->getName());
if ($preview_attachment->getFilename()) {
$title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')';
}
return sprintf(
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
'Part image',
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
'img-fluid hoverpic',
$title
);
}
}

View file

@ -37,6 +37,7 @@ use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\PartStockChangedLogEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
@ -190,7 +191,16 @@ class LogDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('log.type'),
'propertyPath' => 'type',
'render' => function (string $value, AbstractLogEntry $context) {
return $this->translator->trans('log.type.'.$value);
$text = $this->translator->trans('log.type.'.$value);
if ($context instanceof PartStockChangedLogEntry) {
$text .= sprintf(
' (<i>%s</i>)',
$this->translator->trans('log.part_stock_changed.' . $context->getInstockChangeType())
);
}
return $text;
},
]);

View file

@ -34,6 +34,7 @@ use App\DataTables\Column\SIUnitNumberColumn;
use App\DataTables\Column\TagsColumn;
use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -63,26 +64,27 @@ final class PartsDataTable implements DataTableTypeInterface
private TranslatorInterface $translator;
private NodesListBuilder $treeBuilder;
private AmountFormatter $amountFormatter;
private PartPreviewGenerator $previewGenerator;
private AttachmentURLGenerator $attachmentURLGenerator;
private Security $security;
private PartDataTableHelper $partDataTableHelper;
/**
* @var EntityURLGenerator
*/
private $urlGenerator;
public function __construct(EntityURLGenerator $urlGenerator, TranslatorInterface $translator,
NodesListBuilder $treeBuilder, AmountFormatter $amountFormatter,
PartPreviewGenerator $previewGenerator, AttachmentURLGenerator $attachmentURLGenerator, Security $security)
NodesListBuilder $treeBuilder, AmountFormatter $amountFormatter,PartDataTableHelper $partDataTableHelper,
AttachmentURLGenerator $attachmentURLGenerator, Security $security)
{
$this->urlGenerator = $urlGenerator;
$this->translator = $translator;
$this->treeBuilder = $treeBuilder;
$this->amountFormatter = $amountFormatter;
$this->previewGenerator = $previewGenerator;
$this->attachmentURLGenerator = $attachmentURLGenerator;
$this->security = $security;
$this->partDataTableHelper = $partDataTableHelper;
}
public function configureOptions(OptionsResolver $optionsResolver): void
@ -122,46 +124,13 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, Part $context) {
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
if (null === $preview_attachment) {
return '';
}
$title = htmlspecialchars($preview_attachment->getName());
if ($preview_attachment->getFilename()) {
$title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')';
}
return sprintf(
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
'Part image',
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
'img-fluid hoverpic',
$title
);
return $this->partDataTableHelper->renderPicture($context);
},
])
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'render' => function ($value, Part $context) {
$icon = '';
//Depending on the part status we show a different icon (the later conditions have higher priority)
if ($context->isFavorite()) {
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>', $this->translator->trans('part.favorite.badge'));
}
if ($context->isNeedsReview()) {
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>', $this->translator->trans('part.needs_review.badge'));
}
return sprintf(
'<a href="%s">%s%s</a>',
$this->urlGenerator->infoURL($context),
$icon,
htmlentities($context->getName())
);
return $this->partDataTableHelper->renderName($context);
},
])
->add('id', TextColumn::class, [

View file

@ -0,0 +1,186 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Column\SelectColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
protected TranslatorInterface $translator;
protected PartDataTableHelper $partDataTableHelper;
protected EntityURLGenerator $entityURLGenerator;
protected AmountFormatter $amountFormatter;
public function __construct(TranslatorInterface $translator, PartDataTableHelper $partDataTableHelper,
EntityURLGenerator $entityURLGenerator, AmountFormatter $amountFormatter)
{
$this->translator = $translator;
$this->partDataTableHelper = $partDataTableHelper;
$this->entityURLGenerator = $entityURLGenerator;
$this->amountFormatter = $amountFormatter;
}
public function configure(DataTable $dataTable, array $options)
{
$dataTable
//->add('select', SelectColumn::class)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() === null) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
},
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
'visible' => false,
])
->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
'render' => function ($value, ProjectBOMEntry $context) {
//If we have a non-part entry, only show the rounded quantity
if ($context->getPart() === null) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
return $this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit());
},
])
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderable' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if($context->getPart() === null) {
return $context->getName();
}
if($context->getPart() !== null) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if(!empty($context->getName())) {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
}
},
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'data' => function (ProjectBOMEntry $context) {
if($context->getPart() !== null) {
return $context->getPart()->getDescription();
}
//For non-part BOM entries show the comment field
return $context->getComment();
},
])
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
])
->add('mountnames', TextColumn::class, [
'label' => 'project.bom.mountnames',
'render' => function ($value, ProjectBOMEntry $context) {
$html = '';
foreach (explode(',', $context->getMountnames()) as $mountname) {
$html .= sprintf('<span class="badge badge-secondary bg-secondary">%s</span> ', htmlspecialchars($mountname));
}
return $html;
},
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
'visible' => false,
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
'visible' => false,
])
;
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
},
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
]);
}
private function getQuery(QueryBuilder $builder, array $options): void
{
$builder->select('bom_entry')
->addSelect('part')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->where('bom_entry.project = :project')
->setParameter('project', $options['project']);
;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
}
}

View file

@ -18,7 +18,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Helpers;
namespace App\Doctrine\Types;
use Brick\Math\BigDecimal;
use Brick\Math\BigNumber;

View file

@ -20,7 +20,7 @@
declare(strict_types=1);
namespace App\Helpers;
namespace App\Doctrine\Types;
use DateTime;
use DateTimeZone;

View file

@ -44,7 +44,7 @@ use LogicException;
* @ORM\DiscriminatorColumn(name="class_name", type="string")
* @ORM\DiscriminatorMap({
* "PartDB\Part" = "PartAttachment", "Part" = "PartAttachment",
* "PartDB\Device" = "DeviceAttachment", "Device" = "DeviceAttachment",
* "PartDB\Device" = "ProjectAttachment", "Device" = "ProjectAttachment",
* "AttachmentType" = "AttachmentTypeAttachment", "Category" = "CategoryAttachment",
* "Footprint" = "FootprintAttachment", "Manufacturer" = "ManufacturerAttachment",
* "Currency" = "CurrencyAttachment", "Group" = "GroupAttachment",

View file

@ -22,7 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@ -32,12 +32,12 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
* @ORM\Entity()
* @UniqueEntity({"name", "attachment_type", "element"})
*/
class DeviceAttachment extends Attachment
class ProjectAttachment extends Attachment
{
public const ALLOWED_ELEMENT_CLASS = Device::class;
public const ALLOWED_ELEMENT_CLASS = Project::class;
/**
* @var Device the element this attachment is associated with
* @ORM\ManyToOne(targetEntity="App\Entity\Devices\Device", inversedBy="attachments")
* @var Project the element this attachment is associated with
* @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="attachments")
* @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
protected ?AttachmentContainingDBElement $element = null;

View file

@ -41,8 +41,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
* "attachment_type" = "App\Entity\AttachmentType",
* "attachment" = "App\Entity\Attachment",
* "category" = "App\Entity\Attachment",
* "device" = "App\Entity\Device",
* "device_part" = "App\Entity\DevicePart",
* "project" = "App\Entity\ProjectSystem\Project",
* "project_bom_entry" = "App\Entity\ProjectSystem\ProjectBOMEntry",
* "footprint" = "App\Entity\Footprint",
* "group" = "App\Entity\Group",
* "manufacturer" = "App\Entity\Manufacturer",

View file

@ -29,6 +29,7 @@ use Doctrine\ORM\Mapping as ORM;
*
* @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository")
*/
abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement
abstract class
AbstractPartsContainingDBElement extends AbstractStructuralDBElement
{
}

View file

@ -1,142 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Devices;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parameters\DeviceParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
/**
* Class AttachmentType.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\DeviceRepository")
* @ORM\Table(name="`devices`")
*/
class Device extends AbstractPartsContainingDBElement
{
/**
* @ORM\OneToMany(targetEntity="Device", mappedBy="parent")
* @ORM\OrderBy({"name" = "ASC"})
* @var Collection
*/
protected $children;
/**
* @ORM\ManyToOne(targetEntity="Device", inversedBy="children")
* @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
*/
protected $parent;
/**
* @ORM\OneToMany(targetEntity="DevicePart", mappedBy="device")
*/
protected $parts;
/**
* @ORM\Column(type="integer")
*/
protected int $order_quantity = 0;
/**
* @ORM\Column(type="boolean")
*/
protected bool $order_only_missing_parts = false;
/**
* @var Collection<int, DeviceAttachment>
* @ORM\OneToMany(targetEntity="App\Entity\Attachments\DeviceAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"name" = "ASC"})
*/
protected $attachments;
/** @var Collection<int, DeviceParameter>
* @ORM\OneToMany(targetEntity="App\Entity\Parameters\DeviceParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
*/
protected $parameters;
/********************************************************************************
*
* Getters
*
*********************************************************************************/
/**
* Get the order quantity of this device.
*
* @return int the order quantity
*/
public function getOrderQuantity(): int
{
return $this->order_quantity;
}
/**
* Get the "order_only_missing_parts" attribute.
*
* @return bool the "order_only_missing_parts" attribute
*/
public function getOrderOnlyMissingParts(): bool
{
return $this->order_only_missing_parts;
}
/********************************************************************************
*
* Setters
*
*********************************************************************************/
/**
* Set the order quantity.
*
* @param int $new_order_quantity the new order quantity
*
* @return $this
*/
public function setOrderQuantity(int $new_order_quantity): self
{
if ($new_order_quantity < 0) {
throw new InvalidArgumentException('The new order quantity must not be negative!');
}
$this->order_quantity = $new_order_quantity;
return $this;
}
/**
* Set the "order_only_missing_parts" attribute.
*
* @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute
*
* @return Device
*/
public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self
{
$this->order_only_missing_parts = $new_order_only_missing_parts;
return $this;
}
}

View file

@ -1,61 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Devices;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use Doctrine\ORM\Mapping as ORM;
/**
* Class DevicePart.
*
* @ORM\Table("`device_parts`")
* @ORM\Entity()
*/
class DevicePart extends AbstractDBElement
{
/**
* @var int
* @ORM\Column(type="integer", name="quantity")
*/
protected int $quantity;
/**
* @var string
* @ORM\Column(type="text", name="mountnames")
*/
protected string $mountnames;
/**
* @var Device
* @ORM\ManyToOne(targetEntity="Device", inversedBy="parts")
* @ORM\JoinColumn(name="id_device", referencedColumnName="id")
*/
protected ?Device $device = null;
/**
* @var Part
* @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part")
* @ORM\JoinColumn(name="id_part", referencedColumnName="id")
*/
protected ?Part $part = null;
}

View file

@ -25,8 +25,8 @@ namespace App\Entity\LogSystem;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device;
use App\Entity\Devices\DevicePart;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@ -67,10 +67,11 @@ use Psr\Log\LogLevel;
* 6 = "ElementCreatedLogEntry",
* 7 = "ElementEditedLogEntry",
* 8 = "ConfigChangedLogEntry",
* 9 = "InstockChangedLogEntry",
* 9 = "LegacyInstockChangedLogEntry",
* 10 = "DatabaseUpdatedLogEntry",
* 11 = "CollectionElementDeleted",
* 12 = "SecurityEventLogEntry",
* 13 = "PartStockChangedLogEntry",
* })
*/
abstract class AbstractLogEntry extends AbstractDBElement
@ -124,8 +125,8 @@ abstract class AbstractLogEntry extends AbstractDBElement
self::TARGET_TYPE_ATTACHEMENT => Attachment::class,
self::TARGET_TYPE_ATTACHEMENTTYPE => AttachmentType::class,
self::TARGET_TYPE_CATEGORY => Category::class,
self::TARGET_TYPE_DEVICE => Device::class,
self::TARGET_TYPE_DEVICEPART => DevicePart::class,
self::TARGET_TYPE_DEVICE => Project::class,
self::TARGET_TYPE_DEVICEPART => ProjectBOMEntry::class,
self::TARGET_TYPE_FOOTPRINT => Footprint::class,
self::TARGET_TYPE_GROUP => Group::class,
self::TARGET_TYPE_MANUFACTURER => Manufacturer::class,

View file

@ -46,7 +46,7 @@ use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\ManufacturerAttachment;
@ -58,12 +58,12 @@ use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\CurrencyParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
@ -159,8 +159,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
return CategoryParameter::class;
case Currency::class:
return CurrencyParameter::class;
case Device::class:
return DeviceParameter::class;
case Project::class:
return ProjectParameter::class;
case Footprint::class:
return FootprintParameter::class;
case Group::class:
@ -189,8 +189,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
return CategoryAttachment::class;
case Currency::class:
return CurrencyAttachment::class;
case Device::class:
return DeviceAttachment::class;
case Project::class:
return ProjectAttachment::class;
case Footprint::class:
return FootprintAttachment::class;
case Group::class:

View file

@ -27,7 +27,7 @@ use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class InstockChangedLogEntry extends AbstractLogEntry
class LegacyInstockChangedLogEntry extends AbstractLogEntry
{
protected string $typeString = 'instock_changed';

View file

@ -0,0 +1,224 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Entity\LogSystem;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity()
*/
class PartStockChangedLogEntry extends AbstractLogEntry
{
public const TYPE_ADD = "add";
public const TYPE_WITHDRAW = "withdraw";
public const TYPE_MOVE = "move";
protected string $typeString = 'part_stock_changed';
protected const COMMENT_MAX_LENGTH = 300;
/**
* Creates a new part stock changed log entry.
* @param string $type The type of the log entry. One of the TYPE_* constants.
* @param PartLot $lot The part lot which has been changed.
* @param float $old_stock The old stock of the lot.
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot|null $move_to_target The target lot if the type is TYPE_MOVE.
*/
protected function __construct(string $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null)
{
parent::__construct();
if (!in_array($type, [self::TYPE_ADD, self::TYPE_WITHDRAW, self::TYPE_MOVE], true)) {
throw new \InvalidArgumentException('Invalid type for PartStockChangedLogEntry!');
}
//Same as every other element change log entry
$this->level = self::LEVEL_INFO;
$this->setTargetElement($lot);
$this->typeString = 'part_stock_changed';
$this->extra = array_merge($this->extra, [
't' => $this->typeToShortType($type),
'o' => $old_stock,
'n' => $new_stock,
'p' => $new_total_part_instock,
]);
if (!empty($comment)) {
$this->extra['c'] = mb_strimwidth($comment, 0, self::COMMENT_MAX_LENGTH, '...');
}
if ($move_to_target) {
if ($type !== self::TYPE_MOVE) {
throw new \InvalidArgumentException('The move_to_target parameter can only be set if the type is "move"!');
}
$this->extra['m'] = $move_to_target->getID();
}
}
/**
* Creates a new log entry for adding stock to a lot.
* @param PartLot $lot The part lot which has been changed.
* @param float $old_stock The old stock of the lot.
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @return static
*/
public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
{
return new self(self::TYPE_ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
}
/**
* Creates a new log entry for withdrawing stock from a lot.
* @param PartLot $lot The part lot which has been changed.
* @param float $old_stock The old stock of the lot.
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @return static
*/
public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
{
return new self(self::TYPE_WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
}
/**
* Creates a new log entry for moving stock from a lot to another lot.
* @param PartLot $lot The part lot which has been changed.
* @param float $old_stock The old stock of the lot.
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot $move_to_target The target lot.
*/
public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target): self
{
return new self(self::TYPE_MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target);
}
/**
* Returns the instock change type of this entry
* @return string One of the TYPE_* constants.
*/
public function getInstockChangeType(): string
{
return $this->shortTypeToType($this->extra['t']);
}
/**
* Returns the old stock of the lot.
* @return float
*/
public function getOldStock(): float
{
return $this->extra['o'];
}
/**
* Returns the new stock of the lot.
* @return float
*/
public function getNewStock(): float
{
return $this->extra['n'];
}
/**
* Returns the new total instock of the part.
* @return float
*/
public function getNewTotalPartInstock(): float
{
return $this->extra['p'];
}
/**
* Returns the comment associated with the change.
* @return string
*/
public function getComment(): string
{
return $this->extra['c'] ?? '';
}
/**
* Gets the difference between the old and the new stock value of the lot as a positive number.
* @return float
*/
public function getChangeAmount(): float
{
return abs($this->getNewStock() - $this->getOldStock());
}
/**
* Returns the target lot ID (where the instock was moved to) if the type is TYPE_MOVE.
* @return int|null
*/
public function getMoveToTargetID(): ?int
{
return $this->extra['m'] ?? null;
}
/**
* Converts the human-readable type (TYPE_* consts) to the version stored in DB
* @param string $type
* @return string
*/
protected function typeToShortType(string $type): string
{
switch ($type) {
case self::TYPE_ADD:
return 'a';
case self::TYPE_WITHDRAW:
return 'w';
case self::TYPE_MOVE:
return 'm';
default:
throw new \InvalidArgumentException('Invalid type: '.$type);
}
}
/**
* Converts the short type stored in DB to the human-readable type (TYPE_* consts).
* @param string $short_type
* @return string
*/
protected function shortTypeToType(string $short_type): string
{
switch ($short_type) {
case 'a':
return self::TYPE_ADD;
case 'w':
return self::TYPE_WITHDRAW;
case 'm':
return self::TYPE_MOVE;
default:
throw new \InvalidArgumentException('Invalid short type: '.$short_type);
}
}
}

View file

@ -62,7 +62,7 @@ use function sprintf;
* @ORM\DiscriminatorMap({
* 0 = "CategoryParameter",
* 1 = "CurrencyParameter",
* 2 = "DeviceParameter",
* 2 = "ProjectParameter",
* 3 = "FootprintParameter",
* 4 = "GroupParameter",
* 5 = "ManufacturerParameter",

View file

@ -41,7 +41,7 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@ -49,13 +49,13 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @UniqueEntity(fields={"name", "group", "element"})
*/
class DeviceParameter extends AbstractParameter
class ProjectParameter extends AbstractParameter
{
public const ALLOWED_ELEMENT_CLASS = Device::class;
public const ALLOWED_ELEMENT_CLASS = Project::class;
/**
* @var Device the element this para is associated with
* @ORM\ManyToOne(targetEntity="App\Entity\Devices\Device", inversedBy="parameters")
* @var Project the element this para is associated with
* @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="parameters")
* @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
protected $element;

View file

@ -25,7 +25,8 @@ namespace App\Entity\Parts;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Devices\Device;
use App\Entity\Parts\PartTraits\ProjectTrait;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\ParametersTrait;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
@ -33,6 +34,7 @@ use App\Entity\Parts\PartTraits\BasicPropertyTrait;
use App\Entity\Parts\PartTraits\InstockTrait;
use App\Entity\Parts\PartTraits\ManufacturerTrait;
use App\Entity\Parts\PartTraits\OrderTrait;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@ -63,11 +65,7 @@ class Part extends AttachmentContainingDBElement
use ManufacturerTrait;
use OrderTrait;
use ParametersTrait;
/**
* TODO.
*/
protected $devices = [];
use ProjectTrait;
/** @var Collection<int, PartParameter>
* @Assert\Valid()
@ -120,6 +118,7 @@ class Part extends AttachmentContainingDBElement
$this->partLots = new ArrayCollection();
$this->orderdetails = new ArrayCollection();
$this->parameters = new ArrayCollection();
$this->project_bom_entries = new ArrayCollection();
}
public function __clone()
@ -148,16 +147,4 @@ class Part extends AttachmentContainingDBElement
}
parent::__clone();
}
/**
* Get all devices which uses this part.
*
* @return Device[] * all devices which uses this part as a one-dimensional array of Device objects
* (empty array if there are no ones)
* * the array is sorted by the devices names
*/
public function getDevices(): array
{
return $this->devices;
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Entity\Parts\PartTraits;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
trait ProjectTrait
{
/**
* @var Collection<int, ProjectBOMEntry> $project_bom_entries
* @ORM\OneToMany(targetEntity="App\Entity\ProjectSystem\ProjectBOMEntry", mappedBy="part", cascade={"remove"}, orphanRemoval=true)
*/
protected $project_bom_entries = [];
/**
* @var Project|null If a project is set here, then this part is special and represents the builds of a project.
* @ORM\OneToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="build_part")
* @ORM\JoinColumn(nullable=true)
*/
protected ?Project $built_project = null;
/**
* Returns all ProjectBOMEntries that use this part.
* @return Collection<int, ProjectBOMEntry>|ProjectBOMEntry[]
*/
public function getProjectBomEntries(): Collection
{
return $this->project_bom_entries;
}
/**
* Checks whether this part represents the builds of a project
* @return bool True if it represents the builds, false if not
*/
public function isProjectBuildPart(): bool
{
return $this->built_project !== null;
}
/**
* Returns the project that this part represents the builds of, or null if it doesnt
* @return Project|null
*/
public function getBuiltProject(): ?Project
{
return $this->built_project;
}
/**
* Sets the project that this part represents the builds of
* @param Project|null $built_project The project that this part represents the builds of, or null if it is not a build part
*/
public function setBuiltProject(?Project $built_project): self
{
$this->built_project = $built_project;
return $this;
}
/**
* Get all projects which uses this part.
*
* @return Project[] * all devices which uses this part as a one-dimensional array of Device objects
* (empty array if there are no ones)
* * the array is sorted by the devices names
*/
public function getProjects(): array
{
$projects = [];
foreach($this->project_bom_entries as $entry) {
$projects[] = $entry->getProject();
}
return $projects;
}
}

View file

@ -0,0 +1,314 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\ProjectSystem;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parts\Part;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Class AttachmentType.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\DeviceRepository")
* @ORM\Table(name="projects")
*/
class Project extends AbstractStructuralDBElement
{
/**
* @ORM\OneToMany(targetEntity="Project", mappedBy="parent")
* @ORM\OrderBy({"name" = "ASC"})
* @var Collection
*/
protected $children;
/**
* @ORM\ManyToOne(targetEntity="Project", inversedBy="children")
* @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
*/
protected $parent;
/**
* @ORM\OneToMany(targetEntity="ProjectBOMEntry", mappedBy="project", cascade={"persist", "remove"}, orphanRemoval=true)
* @Assert\Valid()
*/
protected $bom_entries;
/**
* @ORM\Column(type="integer")
*/
protected int $order_quantity = 0;
/**
* @var string The current status of the project
* @ORM\Column(type="string", length=64, nullable=true)
* @Assert\Choice({"draft","planning","in_production","finished","archived"})
*/
protected ?string $status = null;
/**
* @var Part|null The (optional) part that represents the builds of this project in the stock
* @ORM\OneToOne(targetEntity="App\Entity\Parts\Part", mappedBy="built_project", cascade={"persist"}, orphanRemoval=true)
*/
protected ?Part $build_part = null;
/**
* @ORM\Column(type="boolean")
*/
protected bool $order_only_missing_parts = false;
/**
* @ORM\Column(type="text", nullable=false, columnDefinition="DEFAULT ''")
*/
protected string $description = '';
/**
* @var Collection<int, ProjectAttachment>
* @ORM\OneToMany(targetEntity="App\Entity\Attachments\ProjectAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"name" = "ASC"})
*/
protected $attachments;
/** @var Collection<int, ProjectParameter>
* @ORM\OneToMany(targetEntity="App\Entity\Parameters\ProjectParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
*/
protected $parameters;
/********************************************************************************
*
* Getters
*
*********************************************************************************/
public function __construct()
{
parent::__construct();
$this->bom_entries = new ArrayCollection();
}
public function __clone()
{
//When cloning this project, we have to clone each bom entry too.
if ($this->id) {
$bom_entries = $this->bom_entries;
$this->bom_entries = new ArrayCollection();
//Set master attachment is needed
foreach ($bom_entries as $bom_entry) {
$clone = clone $bom_entry;
$this->bom_entries->add($clone);
}
}
//Parent has to be last call, as it resets the ID
parent::__clone();
}
/**
* Get the order quantity of this device.
*
* @return int the order quantity
*/
public function getOrderQuantity(): int
{
return $this->order_quantity;
}
/**
* Get the "order_only_missing_parts" attribute.
*
* @return bool the "order_only_missing_parts" attribute
*/
public function getOrderOnlyMissingParts(): bool
{
return $this->order_only_missing_parts;
}
/********************************************************************************
*
* Setters
*
*********************************************************************************/
/**
* Set the order quantity.
*
* @param int $new_order_quantity the new order quantity
*
* @return $this
*/
public function setOrderQuantity(int $new_order_quantity): self
{
if ($new_order_quantity < 0) {
throw new InvalidArgumentException('The new order quantity must not be negative!');
}
$this->order_quantity = $new_order_quantity;
return $this;
}
/**
* Set the "order_only_missing_parts" attribute.
*
* @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute
*
* @return Project
*/
public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self
{
$this->order_only_missing_parts = $new_order_only_missing_parts;
return $this;
}
/**
* @return Collection<int, ProjectBOMEntry>|ProjectBOMEntry[]
*/
public function getBomEntries(): Collection
{
return $this->bom_entries;
}
/**
* @param ProjectBOMEntry $entry
* @return $this
*/
public function addBomEntry(ProjectBOMEntry $entry): self
{
$entry->setProject($this);
$this->bom_entries->add($entry);
return $this;
}
/**
* @param ProjectBOMEntry $entry
* @return $this
*/
public function removeBomEntry(ProjectBOMEntry $entry): self
{
$this->bom_entries->removeElement($entry);
return $this;
}
/**
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* @param string $description
* @return Project
*/
public function setDescription(string $description): Project
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getStatus(): ?string
{
return $this->status;
}
/**
* @param string $status
*/
public function setStatus(?string $status): void
{
$this->status = $status;
}
/**
* Checks if this project has a associated part representing the builds of this project in the stock.
* @return bool
*/
public function hasBuildPart(): bool
{
return $this->build_part !== null;
}
/**
* Gets the part representing the builds of this project in the stock, if it is existing
* @return Part|null
*/
public function getBuildPart(): ?Part
{
return $this->build_part;
}
/**
* Sets the part representing the builds of this project in the stock.
* @param Part|null $build_part
*/
public function setBuildPart(?Part $build_part): void
{
$this->build_part = $build_part;
if ($build_part) {
$build_part->setBuiltProject($this);
}
}
/**
* @Assert\Callback
*/
public function validate(ExecutionContextInterface $context, $payload)
{
//If this project has subprojects, and these have builds part, they must be included in the BOM
foreach ($this->getChildren() as $child) {
/** @var $child Project */
if ($child->getBuildPart() === null) {
continue;
}
//We have to search all bom entries for the build part
$found = false;
foreach ($this->getBomEntries() as $bom_entry) {
if ($bom_entry->getPart() === $child->getBuildPart()) {
$found = true;
break;
}
}
//When the build part is not found, we have to add an error
if (!$found) {
$context->buildViolation('project.bom_has_to_include_all_subelement_parts')
->atPath('bom_entries')
->setParameter('%project_name%', $child->getName())
->setParameter('%part_name%', $child->getBuildPart()->getName())
->addViolation();
}
}
}
}

View file

@ -0,0 +1,324 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\ProjectSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* The ProjectBOMEntry class represents a entry in a project's BOM.
*
* @ORM\Table("project_bom_entries")
* @ORM\HasLifecycleCallbacks()
* @ORM\Entity()
* @UniqueEntity(fields={"part", "project"}, message="project.bom_entry.part_already_in_bom")
* @UniqueEntity(fields={"name", "project"}, message="project.bom_entry.name_already_in_bom", ignoreNull=true)
*/
class ProjectBOMEntry extends AbstractDBElement
{
use TimestampTrait;
/**
* @var float
* @ORM\Column(type="float", name="quantity")
* @Assert\Positive()
*/
protected float $quantity;
/**
* @var string A comma separated list of the names, where this parts should be placed
* @ORM\Column(type="text", name="mountnames")
*/
protected string $mountnames;
/**
* @var string An optional name describing this BOM entry (useful for non-part entries)
* @ORM\Column(type="string", nullable=true)
* @Assert\Expression(
* "this.getPart() !== null or this.getName() !== null",
* message="validator.project.bom_entry.name_or_part_needed"
* )
*/
protected ?string $name = null;
/**
* @var string An optional comment for this BOM entry
* @ORM\Column(type="text")
*/
protected string $comment;
/**
* @var Project
* @ORM\ManyToOne(targetEntity="Project", inversedBy="bom_entries")
* @ORM\JoinColumn(name="id_device", referencedColumnName="id")
*/
protected ?Project $project = null;
/**
* @var Part|null The part associated with this
* @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="project_bom_entries")
* @ORM\JoinColumn(name="id_part", referencedColumnName="id", nullable=true)
*/
protected ?Part $part = null;
/**
* @var BigDecimal The price of this non-part BOM entry
* @ORM\Column(type="big_decimal", precision=11, scale=5, nullable=true)
* @Assert\AtLeastOneOf({
* @BigDecimalPositive(),
* @Assert\IsNull()
* })
*/
protected ?BigDecimal $price;
/**
* @var ?Currency The currency for the price of this non-part BOM entry
* @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency")
* @ORM\JoinColumn(nullable=true)
* @Selectable()
*/
protected ?Currency $price_currency = null;
public function __construct()
{
$this->price = BigDecimal::zero()->toScale(5);
}
/**
* @return float
*/
public function getQuantity(): float
{
return $this->quantity;
}
/**
* @param float $quantity
* @return ProjectBOMEntry
*/
public function setQuantity(float $quantity): ProjectBOMEntry
{
$this->quantity = $quantity;
return $this;
}
/**
* @return string
*/
public function getMountnames(): string
{
return $this->mountnames;
}
/**
* @param string $mountnames
* @return ProjectBOMEntry
*/
public function setMountnames(string $mountnames): ProjectBOMEntry
{
$this->mountnames = $mountnames;
return $this;
}
/**
* @return string
*/
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $name
* @return ProjectBOMEntry
*/
public function setName(?string $name): ProjectBOMEntry
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getComment(): string
{
return $this->comment;
}
/**
* @param string $comment
* @return ProjectBOMEntry
*/
public function setComment(string $comment): ProjectBOMEntry
{
$this->comment = $comment;
return $this;
}
/**
* @return Project|null
*/
public function getProject(): ?Project
{
return $this->project;
}
/**
* @param Project|null $project
* @return ProjectBOMEntry
*/
public function setProject(?Project $project): ProjectBOMEntry
{
$this->project = $project;
return $this;
}
/**
* @return Part|null
*/
public function getPart(): ?Part
{
return $this->part;
}
/**
* @param Part|null $part
* @return ProjectBOMEntry
*/
public function setPart(?Part $part): ProjectBOMEntry
{
$this->part = $part;
return $this;
}
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
* @return BigDecimal|null
*/
public function getPrice(): ?BigDecimal
{
return $this->price;
}
/**
* Sets the price of this BOM entry.
* Prices are only valid on non-Part BOM entries.
* @param BigDecimal|null $price
*/
public function setPrice(?BigDecimal $price): void
{
$this->price = $price;
}
/**
* @return Currency|null
*/
public function getPriceCurrency(): ?Currency
{
return $this->price_currency;
}
/**
* @param Currency|null $price_currency
*/
public function setPriceCurrency(?Currency $price_currency): void
{
$this->price_currency = $price_currency;
}
/**
* @Assert\Callback
*/
public function validate(ExecutionContextInterface $context, $payload): void
{
//Round quantity to whole numbers, if the part is not a decimal part
if ($this->part) {
if (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger()) {
$this->quantity = round($this->quantity);
}
}
//Non-Part BOM entries are rounded
if ($this->part === null) {
$this->quantity = round($this->quantity);
}
//Check that every part name in the mountnames list is unique (per bom_entry)
$mountnames = explode(',', $this->mountnames);
$mountnames = array_map('trim', $mountnames);
$uniq_mountnames = array_unique($mountnames);
//If the number of unique names is not the same as the number of names, there are duplicates
if (count($mountnames) !== count($uniq_mountnames)) {
$context->buildViolation('project.bom_entry.mountnames_not_unique')
->atPath('mountnames')
->addViolation();
}
//Check that the number of mountnames is the same as the (rounded) quantity
if (!empty($this->mountnames) && count($uniq_mountnames) !== (int) round ($this->quantity)) {
$context->buildViolation('project.bom_entry.mountnames_quantity_mismatch')
->atPath('mountnames')
->addViolation();
}
//Prices are only only allowed on non-part BOM entries
if ($this->part !== null && $this->price !== null) {
$context->buildViolation('project.bom_entry.price_not_allowed_on_parts')
->atPath('price')
->addViolation();
}
//Check that the part is not the build representation part of this device or one of its parents
if ($this->part && $this->part->getBuiltProject() !== null) {
//Get the associated project
$associated_project = $this->part->getBuiltProject();
//Check that it is not the same as the current project neither one of its parents
$current_project = $this->project;
while ($current_project) {
if ($associated_project === $current_project) {
$context->buildViolation('project.bom_entry.can_not_add_own_builds_part')
->atPath('part')
->addViolation();
}
$current_project = $current_project->getParent();
}
}
}
}

View file

@ -37,6 +37,11 @@ final class PermissionData implements \JsonSerializable
public const ALLOW = true;
public const DISALLOW = false;
/**
* The current schema version of the permission data
*/
public const CURRENT_SCHEMA_VERSION = 2;
/**
* @var array This array contains the permission values for each permission
* This array contains the permission values for each permission, in the form of:
@ -45,7 +50,10 @@ final class PermissionData implements \JsonSerializable
* ]
* @ORM\Column(type="json", name="data", options={"default": "[]"})
*/
protected ?array $data = [];
protected ?array $data = [
//$ prefixed entries are used for metadata
'$ver' => self::CURRENT_SCHEMA_VERSION, //The schema version of the permission data
];
/**
* Creates a new Permission Data Instance using the given data.
@ -54,6 +62,61 @@ final class PermissionData implements \JsonSerializable
public function __construct(array $data = [])
{
$this->data = $data;
//If the passed data did not contain a schema version, we set it to the current version
if (!isset($this->data['$ver'])) {
$this->data['$ver'] = self::CURRENT_SCHEMA_VERSION;
}
}
/**
* Checks if any of the operations of the given permission is defined (meaning it is either ALLOW or DENY)
* @param string $permission
* @return bool
*/
public function isAnyOperationOfPermissionSet(string $permission): bool
{
return !empty($this->data[$permission]);
}
/**
* Returns an associative array containing all defined (non-INHERIT) operations of the given permission.
* @param string $permission
* @return array An array in the form ["operation" => value], returns an empty array if no operations are defined
*/
public function getAllDefinedOperationsOfPermission(string $permission): array
{
if (empty($this->data[$permission])) {
return [];
}
return $this->data[$permission];
}
/**
* Sets all operations of the given permission via the given array.
* The data is an array in the form [$operation => $value], all existing values will be overwritten/deleted.
* @param string $permission
* @param array $data
* @return $this
*/
public function setAllOperationsOfPermission(string $permission, array $data): self
{
$this->data[$permission] = $data;
return $this;
}
/**
* Removes a whole permission from the data including all operations (effectivly setting them to INHERIT)
* @param string $permission
* @return $this
*/
public function removePermission(string $permission): self
{
unset($this->data[$permission]);
return $this;
}
/**
@ -64,6 +127,11 @@ final class PermissionData implements \JsonSerializable
*/
public function isPermissionSet(string $permission, string $operation): bool
{
//We cannot access metadata via normal permission data
if (strpos($permission, '$') !== false) {
return false;
}
return isset($this->data[$permission][$operation]);
}
@ -143,6 +211,11 @@ final class PermissionData implements \JsonSerializable
//Filter out all empty or null values
foreach ($this->data as $permission => $operations) {
//Skip non-array values
if (!is_array($operations)) {
continue;
}
$ret[$permission] = array_filter($operations, function ($value) {
return $value !== null;
});
@ -155,4 +228,29 @@ final class PermissionData implements \JsonSerializable
return $ret;
}
/**
* Returns the schema version of the permission data.
* @return int The schema version of the permission data
*/
public function getSchemaVersion(): int
{
return $this->data['$ver'] ?? 0;
}
/**
* Sets the schema version of this permission data
* @param int $new_version
* @return $this
*/
public function setSchemaVersion(int $new_version): self
{
if ($new_version < 0) {
throw new \InvalidArgumentException('The schema version must be a positive integer');
}
$this->data['$ver'] = $new_version;
return $this;
}
}

View file

@ -0,0 +1,77 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\EventSubscriber\UserSystem;
use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\UserSystem\PermissionSchemaUpdater;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Security;
/**
* The purpose of this event subscriber is to check if the permission schema of the current user is up to date and upgrade it automatically if needed.
*/
class UpgradePermissionsSchemaSubscriber implements EventSubscriberInterface
{
private Security $security;
private PermissionSchemaUpdater $permissionSchemaUpdater;
private EntityManagerInterface $entityManager;
private FlashBagInterface $flashBag;
private EventCommentHelper $eventCommentHelper;
public function __construct(Security $security, PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager, FlashBagInterface $flashBag, EventCommentHelper $eventCommentHelper)
{
$this->security = $security;
$this->permissionSchemaUpdater = $permissionSchemaUpdater;
$this->entityManager = $entityManager;
$this->flashBag = $flashBag;
$this->eventCommentHelper = $eventCommentHelper;
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
if (null === $user) {
//Retrieve anonymous user
$user = $this->entityManager->getRepository(User::class)->getAnonymousUser();
}
if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) {
$this->eventCommentHelper->setMessage('Automatic permission schema update');
$this->permissionSchemaUpdater->userUpgradeSchemaRecursively($user);
$this->entityManager->flush();
$this->flashBag->add('notice', 'user.permissions_schema_updated');
}
}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => 'onRequest'];
}
}

View file

@ -0,0 +1,64 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\ProjectSystem\ProjectBOMEntryCollectionType;
use App\Form\ProjectSystem\ProjectBOMEntryType;
use App\Form\Type\RichTextEditorType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
class ProjectAdminForm extends BaseEntityAdminForm
{
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
{
$builder->add('description', RichTextEditorType::class, [
'required' => false,
'label' => 'part.edit.description',
'mode' => 'markdown-single_line',
'empty_data' => '',
'attr' => [
'placeholder' => 'part.edit.description.placeholder',
'rows' => 2,
],
]);
$builder->add('bom_entries', ProjectBOMEntryCollectionType::class);
$builder->add('status', ChoiceType::class, [
'attr' => [
'class' => 'form-select',
],
'label' => 'project.edit.status',
'required' => false,
'empty_data' => '',
'choices' => [
'project.status.draft' => 'draft',
'project.status.planning' => 'planning',
'project.status.in_production' => 'in_production',
'project.status.finished' => 'finished',
'project.status.archived' => 'archived',
],
]);
}
}

View file

@ -38,21 +38,21 @@ class StorelocationAdminForm extends BaseEntityAdminForm
'required' => false,
'label' => 'storelocation.edit.is_full.label',
'help' => 'storelocation.edit.is_full.help',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity),
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
$builder->add('limit_to_existing_parts', CheckboxType::class, [
'required' => false,
'label' => 'storelocation.limit_to_existing.label',
'help' => 'storelocation.limit_to_existing.help',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity),
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
$builder->add('only_single_part', CheckboxType::class, [
'required' => false,
'label' => 'storelocation.only_single_part.label',
'help' => 'storelocation.only_single_part.help',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity),
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
$builder->add('storage_type', StructuralEntityType::class, [
@ -61,7 +61,7 @@ class StorelocationAdminForm extends BaseEntityAdminForm
'help' => 'storelocation.storage_type.help',
'class' => MeasurementUnit::class,
'disable_not_selectable' => true,
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity),
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
}
}

View file

@ -25,7 +25,7 @@ use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\LabelAttachment;
@ -80,7 +80,7 @@ class AttachmentFilterType extends AbstractType
'attachment_type.label' => AttachmentTypeAttachment::class,
'category.label' => CategoryAttachment::class,
'currency.label' => CurrencyAttachment::class,
'device.label' => DeviceAttachment::class,
'project.label' => ProjectAttachment::class,
'footprint.label' => FootprintAttachment::class,
'group.label' => GroupAttachment::class,
'label_profile.label' => LabelAttachment::class,

View file

@ -23,8 +23,9 @@ namespace App\Form\Filters;
use App\DataTables\Filters\LogFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Devices\Device;
use App\Entity\Devices\DevicePart;
use App\Entity\LogSystem\PartStockChangedLogEntry;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\LogSystem\CollectionElementDeleted;
@ -32,7 +33,7 @@ use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\InstockChangedLogEntry;
use App\Entity\LogSystem\LegacyInstockChangedLogEntry;
use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
@ -86,9 +87,10 @@ class LogFilterType extends AbstractType
'log.type.user_login' => UserLoginLogEntry::class,
'log.type.user_logout' => UserLogoutLogEntry::class,
'log.type.user_not_allowed' => UserNotAllowedLogEntry::class,
'log.type.part_stock_changed' => PartStockChangedLogEntry::class,
//Legacy entries
'log.type.instock_changed' => InstockChangedLogEntry::class,
'log.type.instock_changed' => LegacyInstockChangedLogEntry::class,
];
public function configureOptions(OptionsResolver $resolver): void
@ -135,8 +137,8 @@ class LogFilterType extends AbstractType
'attachment.label' => AbstractLogEntry::targetTypeClassToID(Attachment::class),
'attachment_type.label' => AbstractLogEntry::targetTypeClassToID(AttachmentType::class),
'category.label' => AbstractLogEntry::targetTypeClassToID(Category::class),
'device.label' => AbstractLogEntry::targetTypeClassToID(Device::class),
'device_part.label' => AbstractLogEntry::targetTypeClassToID(DevicePart::class),
'project.label' => AbstractLogEntry::targetTypeClassToID(Project::class),
'project_bom_entry.label' => AbstractLogEntry::targetTypeClassToID(ProjectBOMEntry::class),
'footprint.label' => AbstractLogEntry::targetTypeClassToID(Footprint::class),
'group.label' => AbstractLogEntry::targetTypeClassToID(Group::class),
'manufacturer.label' => AbstractLogEntry::targetTypeClassToID(Manufacturer::class),

View file

@ -45,7 +45,7 @@ use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\CurrencyParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
@ -158,7 +158,7 @@ class ParameterType extends AbstractType
AttachmentTypeParameter::class => 'attachment_type',
CategoryParameter::class => 'category',
CurrencyParameter::class => 'currency',
DeviceParameter::class => 'device',
ProjectParameter::class => 'device',
FootprintParameter::class => 'footprint',
GroupParameter::class => 'group',
ManufacturerParameter::class => 'manufacturer',

View file

@ -0,0 +1,35 @@
<?php
namespace App\Form\ProjectSystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectBOMEntryCollectionType extends AbstractType
{
public function getParent()
{
return CollectionType::class;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'entry_type' => ProjectBOMEntryType::class,
'entry_options' => [
'label' => false,
],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'reindex_enable' => true,
'label' => false,
]);
}
public function getBlockPrefix()
{
return 'project_bom_entry_collection';
}
}

View file

@ -0,0 +1,100 @@
<?php
namespace App\Form\ProjectSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\PartSelectType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use Svg\Tag\Text;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectBOMEntryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var ProjectBOMEntry $data */
$data = $event->getData();
$form->add('quantity', SIUnitType::class, [
'label' => 'project.bom.quantity',
'measurement_unit' => $data && $data->getPart() ? $data->getPart()->getPartUnit() : null,
]);
});
$builder
->add('part', PartSelectType::class, [
'required' => false,
])
->add('name', TextType::class, [
'label' => 'project.bom.name',
'required' => false,
])
->add('mountnames', TextType::class, [
'required' => false,
'label' => 'project.bom.mountnames',
'empty_data' => '',
'attr' => [
'class' => 'tagsinput',
'data-controller' => 'elements--tagsinput',
]
])
->add('comment', RichTextEditorType::class, [
'required' => false,
'label' => 'project.bom.comment',
'empty_data' => '',
'mode' => 'markdown-single_line',
'attr' => [
'rows' => 2,
],
])
->add('price', BigDecimalNumberType::class, [
'label' => false,
'required' => false,
'scale' => 5,
'html5' => true,
'attr' => [
'min' => 0,
'step' => 'any',
],
])
->add('priceCurrency', CurrencyEntityType::class, [
'required' => false,
'label' => false,
'short' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ProjectBOMEntry::class,
]);
}
public function getBlockPrefix()
{
return 'project_bom_entry';
}
}

View file

@ -0,0 +1,137 @@
<?php
namespace App\Form\Type;
use App\Entity\Parts\Part;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\PartPreviewGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class PartSelectType extends AbstractType implements DataMapperInterface
{
private UrlGeneratorInterface $urlGenerator;
private EntityManagerInterface $em;
private PartPreviewGenerator $previewGenerator;
private AttachmentURLGenerator $attachmentURLGenerator;
public function __construct(UrlGeneratorInterface $urlGenerator, EntityManagerInterface $em, PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator)
{
$this->urlGenerator = $urlGenerator;
$this->em = $em;
$this->previewGenerator = $previewGenerator;
$this->attachmentURLGenerator = $attachmentURLGenerator;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
//At initialization we have to fill the form element with our selected data, so the user can see it
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
$config = $form->getConfig()->getOptions();
$data = $event->getData() ?? [];
$config['compound'] = false;
$config['choices'] = is_iterable($data) ? $data : [$data];
$config['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $config);
});
//After form submit, we have to add the selected element as choice, otherwise the form will not accept this element
$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
$options = $form->get('autocomplete')->getConfig()->getOptions();
if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
$options['choices'] = [];
} else {
//Extract the ID from the submitted data
$id = $data['autocomplete'];
//Find the element in the database
$element = $this->em->find($options['class'], $id);
//Add the element as choice
$options['choices'] = [$element];
$options['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $options);
}
});
$builder->setDataMapper($this);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'class' => Part::class,
'choice_label' => 'name',
//'placeholder' => 'None',
'compound' => true,
'error_bubbling' => false,
]);
$resolver->setDefaults([
'attr' => [
'data-controller' => 'elements--part-select',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']),
//Disable browser autocomplete
'autocomplete' => 'off',
],
]);
$resolver->setDefaults([
//Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request
'choice_attr' => ChoiceList::attr($this, function (?Part $part) {
if($part) {
//Determine the picture to show:
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($part);
if ($preview_attachment !== null) {
$preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment,
'thumbnail_sm');
} else {
$preview_url = '';
}
}
return $part ? [
'data-description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'data-category' => $part->getCategory() ? $part->getCategory()->getName() : '',
'data-footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '',
'data-image' => $preview_url,
] : [];
})
]);
}
public function getBlockPrefix()
{
return 'part_select';
}
public function mapDataToForms($data, $forms)
{
$form = current(iterator_to_array($forms, false));
$form->setData($data);
}
public function mapFormsToData($forms, &$data)
{
$form = current(iterator_to_array($forms, false));
$data = $form->getData();
}
}

View file

@ -66,4 +66,25 @@ class PartRepository extends NamedDBElementRepository
return (int) ($query->getSingleScalarResult() ?? 0);
}
public function autocompleteSearch(string $query, int $max_limits = 50): array
{
$qb = $this->createQueryBuilder('part');
$qb->select('part')
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
->where('part.name LIKE :query')
->orWhere('part.description LIKE :query')
->orWhere('category.name LIKE :query')
->orWhere('footprint.name LIKE :query')
;
$qb->setParameter('query', '%'.$query.'%');
$qb->setMaxResults($max_limits);
$qb->orderBy('part.name', 'ASC');
return $qb->getQuery()->getResult();
}
}

View file

@ -22,18 +22,19 @@ namespace App\Repository\Parts;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Repository\AbstractPartsContainingRepository;
use App\Repository\StructuralDBElementRepository;
use InvalidArgumentException;
class DeviceRepository extends AbstractPartsContainingRepository
class DeviceRepository extends StructuralDBElementRepository
{
public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
{
if (!$element instanceof Device) {
if (!$element instanceof Project) {
throw new InvalidArgumentException('$element must be an Device!');
}
@ -44,7 +45,7 @@ class DeviceRepository extends AbstractPartsContainingRepository
public function getPartsCount(object $element): int
{
if (!$element instanceof Device) {
if (!$element instanceof Project) {
throw new InvalidArgumentException('$element must be an Device!');
}

View file

@ -24,7 +24,7 @@ use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
use App\Entity\Parameters\CurrencyParameter;
use App\Entity\Parameters\DeviceParameter;
use App\Entity\Parameters\ProjectParameter;
use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
@ -95,8 +95,8 @@ class ParameterVoter extends ExtendedVoter
$param = 'categories';
} elseif ($subject instanceof CurrencyParameter) {
$param = 'currencies';
} elseif ($subject instanceof DeviceParameter) {
$param = 'devices';
} elseif ($subject instanceof ProjectParameter) {
$param = 'projects';
} elseif ($subject instanceof FootprintParameter) {
$param = 'footprints';
} elseif ($subject instanceof GroupParameter) {

View file

@ -57,7 +57,7 @@ class PartLotVoter extends ExtendedVoter
$this->security = $security;
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected function voteOnUser(string $attribute, $subject, User $user): bool
{
@ -65,6 +65,11 @@ class PartLotVoter extends ExtendedVoter
throw new \RuntimeException('This voter can only handle PartLot objects!');
}
if (in_array($attribute, ['withdraw', 'add', 'move']))
{
return $this->resolver->inherit($user, 'parts_stock', $attribute) ?? false;
}
switch ($attribute) {
case 'read':
$operation = 'read';

View file

@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -40,7 +40,7 @@ class StructureVoter extends ExtendedVoter
protected const OBJ_PERM_MAP = [
AttachmentType::class => 'attachment_types',
Category::class => 'categories',
Device::class => 'devices',
Project::class => 'projects',
Footprint::class => 'footprints',
Manufacturer::class => 'manufacturers',
Storelocation::class => 'storelocations',

View file

@ -28,7 +28,7 @@ use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\DeviceAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\ManufacturerAttachment;
@ -82,7 +82,7 @@ class AttachmentSubmitHandler
AttachmentTypeAttachment::class => 'attachment_type',
CategoryAttachment::class => 'category',
CurrencyAttachment::class => 'currency',
DeviceAttachment::class => 'device',
ProjectAttachment::class => 'device',
FootprintAttachment::class => 'footprint',
GroupAttachment::class => 'group',
ManufacturerAttachment::class => 'manufacturer',

View file

@ -62,6 +62,13 @@ class PartPreviewGenerator
}
}
if (null !== $part->getBuiltProject()) {
$attachment = $part->getBuiltProject()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
}
if (null !== $part->getCategory()) {
$attachment = $part->getCategory()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
@ -109,7 +116,7 @@ class PartPreviewGenerator
return $attachment;
}
//Otherwise check if the part has a footprint with a valid masterattachment
//Otherwise check if the part has a footprint with a valid master attachment
if (null !== $part->getFootprint()) {
$attachment = $part->getFootprint()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
@ -117,6 +124,14 @@ class PartPreviewGenerator
}
}
//With lowest priority use the master attachment of the project this part represents (when existing)
if (null !== $part->getBuiltProject()) {
$attachment = $part->getBuiltProject()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
}
}
//If nothing is available return null
return null;
}

View file

@ -25,7 +25,7 @@ namespace App\Services;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
@ -39,6 +39,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
@ -59,7 +60,8 @@ class ElementTypeNameGenerator
Attachment::class => $this->translator->trans('attachment.label'),
Category::class => $this->translator->trans('category.label'),
AttachmentType::class => $this->translator->trans('attachment_type.label'),
Device::class => $this->translator->trans('device.label'),
Project::class => $this->translator->trans('project.label'),
ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'),
Footprint::class => $this->translator->trans('footprint.label'),
Manufacturer::class => $this->translator->trans('manufacturer.label'),
MeasurementUnit::class => $this->translator->trans('measurement_unit.label'),

View file

@ -26,7 +26,7 @@ use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -113,7 +113,7 @@ class EntityURLGenerator
//As long we does not have own things for it use edit page
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Device::class => 'device_edit',
Project::class => 'project_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
Storelocation::class => 'store_location_edit',
@ -204,7 +204,7 @@ class EntityURLGenerator
//As long we does not have own things for it use edit page
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Device::class => 'device_edit',
Project::class => 'project_info',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
Storelocation::class => 'store_location_edit',
@ -234,7 +234,7 @@ class EntityURLGenerator
Part::class => 'part_edit',
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Device::class => 'device_edit',
Project::class => 'project_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
Storelocation::class => 'store_location_edit',
@ -264,7 +264,7 @@ class EntityURLGenerator
Part::class => 'part_new',
AttachmentType::class => 'attachment_type_new',
Category::class => 'category_new',
Device::class => 'device_new',
Project::class => 'project_new',
Supplier::class => 'supplier_new',
Manufacturer::class => 'manufacturer_new',
Storelocation::class => 'store_location_new',
@ -295,7 +295,7 @@ class EntityURLGenerator
Part::class => 'part_clone',
AttachmentType::class => 'attachment_type_clone',
Category::class => 'category_clone',
Device::class => 'device_clone',
Project::class => 'device_clone',
Supplier::class => 'supplier_clone',
Manufacturer::class => 'manufacturer_clone',
Storelocation::class => 'store_location_clone',
@ -322,6 +322,8 @@ class EntityURLGenerator
public function listPartsURL(AbstractDBElement $entity): string
{
$map = [
Project::class => 'project_info',
Category::class => 'part_list_category',
Footprint::class => 'part_list_footprint',
Manufacturer::class => 'part_list_manufacturer',
@ -338,7 +340,7 @@ class EntityURLGenerator
Part::class => 'part_delete',
AttachmentType::class => 'attachment_type_delete',
Category::class => 'category_delete',
Device::class => 'device_delete',
Project::class => 'project_delete',
Supplier::class => 'supplier_delete',
Manufacturer::class => 'manufacturer_delete',
Storelocation::class => 'store_location_delete',

View file

@ -46,6 +46,7 @@ use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
class HistoryHelper
{
@ -81,6 +82,10 @@ class HistoryHelper
$array = array_merge($array, $element->getParameters()->toArray());
}
if ($element instanceof Project) {
$array = array_merge($array, $element->getBomEntries()->toArray());
}
return $array;
}
}

View file

@ -31,11 +31,13 @@ use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\ExceptionLogEntry;
use App\Entity\LogSystem\InstockChangedLogEntry;
use App\Entity\LogSystem\LegacyInstockChangedLogEntry;
use App\Entity\LogSystem\PartStockChangedLogEntry;
use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Entity\LogSystem\UserNotAllowedLogEntry;
use App\Entity\Parts\PartLot;
use App\Services\ElementTypeNameGenerator;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -155,7 +157,7 @@ class LogEntryExtraFormatter
$array['log.element_edited.changed_fields'] = htmlspecialchars(implode(', ', $context->getChangedFields()));
}
if ($context instanceof InstockChangedLogEntry) {
if ($context instanceof LegacyInstockChangedLogEntry) {
$array[] = $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added');
$array[] = sprintf(
'%s <i class="fas fa-long-arrow-alt-right"></i> %s (%s)',
@ -179,6 +181,23 @@ class LogEntryExtraFormatter
$array[] = htmlspecialchars($context->getMessage());
}
if ($context instanceof PartStockChangedLogEntry) {
$array['log.part_stock_changed.change'] = sprintf("%s %s %s (%s)",
$context->getOldStock(),
'<i class="fa-solid fa-right-long"></i>',
$context->getNewStock(),
($context->getNewStock() > $context->getOldStock() ? '+' : '-'). $context->getChangeAmount(),
);
if (!empty($context->getComment())) {
$array['log.part_stock_changed.comment'] = htmlspecialchars($context->getComment());
}
if ($context->getInstockChangeType() === PartStockChangedLogEntry::TYPE_MOVE) {
$array['log.part_stock_changed.move_target'] =
$this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class)
.' ' . $context->getMoveToTargetID();
}
}
return $array;
}
}

View file

@ -0,0 +1,202 @@
<?php
namespace App\Services\Parts;
use App\Entity\LogSystem\PartStockChangedLogEntry;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\EventLogger;
final class PartLotWithdrawAddHelper
{
private EventLogger $eventLogger;
private EventCommentHelper $eventCommentHelper;
public function __construct(EventLogger $eventLogger, EventCommentHelper $eventCommentHelper)
{
$this->eventLogger = $eventLogger;
$this->eventCommentHelper = $eventCommentHelper;
}
/**
* Checks whether the given part can
* @param PartLot $partLot
* @return bool
*/
public function canAdd(PartLot $partLot): bool
{
//We cannot add or withdraw parts from lots with unknown instock value.
if($partLot->isInstockUnknown()) {
return false;
}
//So far all other restrictions are defined at the storelocation level
if($partLot->getStorageLocation() === null) {
return true;
}
//We can not add parts if the storage location of the lot is marked as full
if($partLot->getStorageLocation()->isFull()) {
return false;
}
return true;
}
public function canWithdraw(PartLot $partLot): bool
{
//We cannot add or withdraw parts from lots with unknown instock value.
if ($partLot->isInstockUnknown()) {
return false;
}
//Part must contain more than 0 parts
if ($partLot->getAmount() <= 0) {
return false;
}
return true;
}
/**
* Withdraw the specified amount of parts from the given part lot.
* Please note that the changes are not flushed to DB yet, you have to do this yourself
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
* @return PartLot The modified part lot
*/
public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot
{
//Ensure that amount is positive
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
$part = $partLot->getPart();
//Check whether we have to round the amount
if (!$part->useFloatAmount()) {
$amount = round($amount);
}
//Ensure that we can withdraw from the part lot
if (!$this->canWithdraw($partLot)) {
throw new \RuntimeException("Cannot withdraw from this part lot!");
}
//Ensure that there is enough stock to withdraw
if ($amount > $partLot->getAmount()) {
throw new \RuntimeException('Not enough stock to withdraw!');
}
//Subtract the amount from the part lot
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount - $amount);
$event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
$this->eventCommentHelper->setMessage($comment);
}
return $partLot;
}
/**
* Add the specified amount of parts to the given part lot.
* Please note that the changes are not flushed to DB yet, you have to do this yourself
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
* @return PartLot The modified part lot
*/
public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
$part = $partLot->getPart();
//Check whether we have to round the amount
if (!$part->useFloatAmount()) {
$amount = round($amount);
}
//Ensure that we can add to the part lot
if (!$this->canAdd($partLot)) {
throw new \RuntimeException("Cannot add to this part lot!");
}
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount + $amount);
$event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
$this->eventCommentHelper->setMessage($comment);
}
return $partLot;
}
/**
* Move the specified amount of parts from the given source part lot to the given target part lot.
* Please note that the changes are not flushed to DB yet, you have to do this yourself
* @param PartLot $origin The part lot from which the parts should be taken
* @param PartLot $target The part lot to which the parts should be added
* @param float $amount The amount of parts that should be moved
* @param string|null $comment A comment describing the reason for the move
* @return void
*/
public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
$part = $origin->getPart();
//Ensure that both part lots belong to the same part
if($origin->getPart() !== $target->getPart()) {
throw new \RuntimeException("Cannot move instock between different parts!");
}
//Check whether we have to round the amount
if (!$part->useFloatAmount()) {
$amount = round($amount);
}
//Ensure that we can withdraw from origin and add to target
if (!$this->canWithdraw($origin) || !$this->canAdd($target)) {
throw new \RuntimeException("Cannot move instock between these part lots!");
}
//Ensure that there is enough stock to withdraw
if ($amount > $origin->getAmount()) {
throw new \RuntimeException('Not enough stock to withdraw!');
}
$oldOriginAmount = $origin->getAmount();
//Subtract the amount from the part lot
$origin->setAmount($origin->getAmount() - $amount);
//And add it to the target
$target->setAmount($target->getAmount() + $amount);
$event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
$this->eventCommentHelper->setMessage($comment);
}
}
}

View file

@ -29,6 +29,8 @@ use App\Repository\DBElementRepository;
use App\Repository\PartRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
@ -36,11 +38,13 @@ final class PartsTableActionHandler
{
private EntityManagerInterface $entityManager;
private Security $security;
private UrlGeneratorInterface $urlGenerator;
public function __construct(EntityManagerInterface $entityManager, Security $security)
public function __construct(EntityManagerInterface $entityManager, Security $security, UrlGeneratorInterface $urlGenerator)
{
$this->entityManager = $entityManager;
$this->security = $security;
$this->urlGenerator = $urlGenerator;
}
/**
@ -62,9 +66,21 @@ final class PartsTableActionHandler
/**
* @param Part[] $selected_parts
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
*/
public function handleAction(string $action, array $selected_parts, ?int $target_id): void
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
{
if ($action === 'add_to_project') {
return new RedirectResponse(
$this->urlGenerator->generate('project_add_parts', [
'id' => $target_id,
'parts' => implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)),
'_redirect' => $redirect_url
])
);
}
//Iterate over the parts and apply the action to it:
foreach ($selected_parts as $part) {
if (!$part instanceof Part) {
@ -116,6 +132,8 @@ final class PartsTableActionHandler
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');
}
}
return null;
}
/**

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\ProjectSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
class ProjectBuildPartHelper
{
/**
* Returns a part that represents the builds of a project. This part is not saved to the database, and can be used
* as initial data for the new part form.
* @param Project $project
* @return Part
*/
public function getPartInitialization(Project $project): Part
{
$part = new Part();
//Associate the part with the project
$part->setBuiltProject($project);
//Set the name of the part to the name of the project
$part->setName($project->getName());
//Set the description of the part to the description of the project
$part->setDescription($project->getDescription());
//Add a tag to the part that indicates that it is a build part
$part->setTags('project-build');
return $part;
}
}

View file

@ -43,7 +43,7 @@ namespace App\Services\Tools;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -111,7 +111,7 @@ class StatisticsHelper
$arr = [
'attachment_type' => AttachmentType::class,
'category' => Category::class,
'device' => Device::class,
'device' => Project::class,
'footprint' => Footprint::class,
'manufacturer' => Manufacturer::class,
'measurement_unit' => MeasurementUnit::class,

View file

@ -24,7 +24,7 @@ namespace App\Services\Trees;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -156,10 +156,10 @@ class ToolsTreeBuilder
$this->urlGenerator->generate('category_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-tags');
}
if ($this->security->isGranted('read', new Device())) {
if ($this->security->isGranted('read', new Project())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.devices'),
$this->urlGenerator->generate('device_new')
$this->translator->trans('tree.tools.edit.projects'),
$this->urlGenerator->generate('project_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
}
if ($this->security->isGranted('read', new Supplier())) {

View file

@ -25,7 +25,7 @@ namespace App\Services\Trees;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
@ -110,7 +110,7 @@ class TreeViewGenerator
}
if ($mode === 'devices') {
$href_type = '';
$href_type = 'list_parts';
}
$generic = $this->getGenericTree($class, $parent);
@ -161,8 +161,8 @@ class TreeViewGenerator
return $this->translator->trans('manufacturer.labelp');
case Supplier::class:
return $this->translator->trans('supplier.labelp');
case Device::class:
return $this->translator->trans('device.labelp');
case Project::class:
return $this->translator->trans('project.labelp');
default:
return $this->translator->trans('tree.root_node.text');
}
@ -182,7 +182,7 @@ class TreeViewGenerator
return $icon . 'fa-industry';
case Supplier::class:
return $icon . 'fa-truck';
case Device::class:
case Project::class:
return $icon . 'fa-archive';
default:
return null;

View file

@ -77,6 +77,11 @@ class PermissionManager
*/
public function dontInherit(HasPermissionsInterface $user, string $permission, string $operation): ?bool
{
//Check that the permission/operation combination is valid
if (! $this->isValidOperation($permission, $operation)) {
throw new InvalidArgumentException('The permission/operation combination "'.$permission.'/'.$operation.'" is not valid!');
}
//Get the permissions from the user
return $user->getPermissions()->getPermissionValue($permission, $operation);
}

View file

@ -102,6 +102,7 @@ class PermissionPresetsHelper
//Set datastructures
$this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts', PermissionData::ALLOW);
$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);
@ -110,6 +111,7 @@ class PermissionPresetsHelper
$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);
//Attachments permissions
$this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW);
@ -149,8 +151,8 @@ class PermissionPresetsHelper
$this->permissionResolver->setPermission($perm_holder, 'labels', 'edit_options', PermissionData::ALLOW);
$this->permissionResolver->setPermission($perm_holder, 'labels', 'read_profiles', PermissionData::ALLOW);
//Set devices permissions
$this->permissionResolver->setPermission($perm_holder, 'devices', 'read', PermissionData::ALLOW);
//Set projects permissions
$this->permissionResolver->setPermission($perm_holder, 'projects', 'read', PermissionData::ALLOW);
return $perm_holder;
}

View file

@ -0,0 +1,148 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\UserSystem;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\PermissionData;
use App\Entity\UserSystem\User;
use App\Security\Interfaces\HasPermissionsInterface;
class PermissionSchemaUpdater
{
/**
* Check if the given user/group needs an update of its permission schema.
* @param HasPermissionsInterface $holder
* @return bool True if the permission schema needs an update, false otherwise.
*/
public function isSchemaUpdateNeeded(HasPermissionsInterface $holder): bool
{
$perm_data = $holder->getPermissions();
if ($perm_data->getSchemaVersion() < PermissionData::CURRENT_SCHEMA_VERSION) {
return true;
}
return false;
}
/**
* Upgrades the permission schema of the given user/group to the chosen version.
* Please note that this function does not flush the changes to DB!
* @param HasPermissionsInterface $holder
* @param int $target_version
* @return bool True, if an upgrade was done, false if it was not needed.
*/
public function upgradeSchema(HasPermissionsInterface $holder, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
{
if ($target_version > PermissionData::CURRENT_SCHEMA_VERSION) {
throw new \InvalidArgumentException('The target version is higher than the maximum possible schema version!');
}
//Check if we need to do an update, if not, return false
if ($target_version <= $holder->getPermissions()->getSchemaVersion()) {
return false;
}
//Do the update
for ($n = $holder->getPermissions()->getSchemaVersion(); $n < $target_version; ++$n) {
$reflectionClass = new \ReflectionClass(self::class);
try {
$method = $reflectionClass->getMethod('upgradeSchemaToVersion'.($n + 1));
//Set the method accessible, so we can call it (needed for PHP < 8.1)
$method->setAccessible(true);
$method->invoke($this, $holder);
} catch (\ReflectionException $e) {
throw new \RuntimeException('Could not find update method for schema version '.($n + 1));
}
//Bump the schema version
$holder->getPermissions()->setSchemaVersion($n + 1);
}
//When we end up here, we have done an upgrade and we can return true
return true;
}
/**
* Upgrades the permission schema of the given group and all of its parent groups to the chosen version.
* Please note that this function does not flush the changes to DB!
* @param Group $group
* @param int $target_version
* @return bool True if an upgrade was done, false if it was not needed.
*/
public function groupUpgradeSchemaRecursively(Group $group, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
{
$updated = $this->upgradeSchema($group, $target_version);
/** @var Group $parent */
$parent = $group->getParent();
while ($parent) {
$updated = $this->upgradeSchema($parent, $target_version) || $updated;
$parent = $parent->getParent();
}
return $updated;
}
/**
* Upgrades the permissions schema of the given users and its parent (including parent groups) to the chosen version.
* Please note that this function does not flush the changes to DB!
* @param User $user
* @param int $target_version
* @return bool True if an upgrade was done, false if it was not needed.
*/
public function userUpgradeSchemaRecursively(User $user, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
{
$updated = $this->upgradeSchema($user, $target_version);
if ($user->getGroup()) {
$updated = $this->groupUpgradeSchemaRecursively($user->getGroup(), $target_version) || $updated;
}
return $updated;
}
private function upgradeSchemaToVersion1(HasPermissionsInterface $holder): void
{
//Use the part edit permission to set the preset value for the new part stock permission
if (
!$holder->getPermissions()->isPermissionSet('parts_stock', 'withdraw')
&& !$holder->getPermissions()->isPermissionSet('parts_stock', 'add')
&& !$holder->getPermissions()->isPermissionSet('parts_stock', 'move')
) { //Only do migration if the permission was not set already
$new_value = $holder->getPermissions()->getPermissionValue('parts', 'edit');
$holder->getPermissions()->setPermissionValue('parts_stock', 'withdraw', $new_value);
$holder->getPermissions()->setPermissionValue('parts_stock', 'add', $new_value);
$holder->getPermissions()->setPermissionValue('parts_stock', 'move', $new_value);
}
}
private function upgradeSchemaToVersion2(HasPermissionsInterface $holder): void
{
//If the projects permissions are not defined yet, rename devices permission to projects (just copy its data over)
if (!$holder->getPermissions()->isAnyOperationOfPermissionSet('projects')) {
$operations_value = $holder->getPermissions()->getAllDefinedOperationsOfPermission('devices');
$holder->getPermissions()->setAllOperationsOfPermission('projects', $operations_value);
$holder->getPermissions()->removePermission('devices');
}
}
}

View file

@ -22,7 +22,7 @@ namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -99,7 +99,7 @@ final class EntityExtension extends AbstractExtension
Storelocation::class => 'storelocation',
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Device::class => 'device',
Project::class => 'device',
Attachment::class => 'attachment',
Supplier::class => 'supplier',
User::class => 'user',

View file

@ -24,7 +24,7 @@ namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;

View file

@ -1,13 +0,0 @@
{% extends "AdminPages/EntityAdminBase.html.twig" %}
{% block card_title %}
<i class="fas fa-archive fa-fw"></i> {% trans %}device.caption{% endtrans %}
{% endblock %}
{% block edit_title %}
{% trans %}device.edit{% endtrans %}: {{ entity.name }}
{% endblock %}
{% block new_title %}
{% trans %}device.new{% endtrans %}
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "AdminPages/EntityAdminBase.html.twig" %}
{# @var entity App\Entity\ProjectSystem\Project #}
{% block card_title %}
<i class="fas fa-archive fa-fw"></i> {% trans %}project.caption{% endtrans %}
{% endblock %}
{% block edit_title %}
{% trans %}project.edit{% endtrans %}: {{ entity.name }}
{% endblock %}
{% block new_title %}
{% trans %}project.new{% endtrans %}
{% endblock %}
{% block additional_pills %}
<li class="nav-item"><a data-bs-toggle="tab" class="nav-link link-anchor" href="#bom">BOM</a></li>
{% endblock %}
{% block quick_links %}
<div class="btn-toolbar">
<div class="btn-group">
<a class="btn btn-outline-secondary" href="{{ entity_url(entity) }}"><i class="fas fa-eye fa-fw"></i></a>
</div>
</div>
{% endblock %}
{% block additional_controls %}
{{ form_row(form.description) }}
{{ form_row(form.status) }}
{% if entity.id %}
<div class="mb-2 row">
<label class="col-form-label col-sm-3">{% trans %}project.edit.associated_build_part{% endtrans %}</label>
<div class="col-sm-9">
{% if entity.buildPart %}
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
{% else %}
<a href="{{ path('part_new_build_part', {"project_id": entity.id , "_redirect": app.request.requestUri}) }}"
class="btn btn-outline-success">{% trans %}project.edit.associated_build_part.add{% endtrans %}</a>
{% endif %}
<p class="text-muted">{% trans %}project.edit.associated_build.hint{% endtrans %}</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block additional_panes %}
<div class="tab-pane" id="bom">
{% form_theme form.bom_entries with ['Form/collection_types_layout.html.twig'] %}
{{ form_errors(form.bom_entries) }}
{{ form_widget(form.bom_entries) }}
</div>
{% endblock %}

View file

@ -0,0 +1,79 @@
{% block project_bom_entry_collection_widget %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<div {{ collection.controller(form, 'project.bom.delete.confirm', 3) }}>
<table class="table table-striped table-bordered table-sm" {{ collection.target() }}>
<thead>
<tr>
<th></th> {# expand button #}
<th>{% trans %}project.bom.quantity{% endtrans %}</th>
<th>{% trans %}project.bom.part{% endtrans %}</th>
<th>{% trans %}project.bom.name{% endtrans %}</th>
<th></th> {# Remove button #}
</tr>
</thead>
<tbody>
{% for entry in form %}
{{ form_widget(entry) }}
{% endfor %}
</tbody>
</table>
<button type="button" class="btn btn-success mb-2" {{ collection.create_btn() }}>
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}project.bom.add_entry{% endtrans %}
</button>
</div>
{% endblock %}
{% block project_bom_entry_widget %}
{% set target_id = 'expand_row-' ~ form.vars.name %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<tr>
<td>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#{{ target_id }}">
<i class="fa-solid fa-eye"></i>
</button>
</td>
<td>
{{ form_errors(form.quantity) }}
{{ form_widget(form.quantity) }}
</td>
<td style="min-width: 250px;">
{{ form_errors(form.part) }}
{{ form_widget(form.part) }}
</td>
<td>
{{ form_errors(form.name) }}
{{ form_widget(form.name) }}
</td>
<td>
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
<i class="fas fa-trash-alt fa-fw"></i>
</button>
{{ form_errors(form) }}
</td>
</tr>
<tr class="p-0 d-none"></tr>
<tr class="p-0">
<td colspan="5" class="accordion-body collapse" id="{{ target_id }}">
<div class="">
{{ form_row(form.mountnames) }}
<div class="row mb-2">
<label class="col-form-label col-sm-3">{% trans %}project.bom.price{% endtrans %}</label>
<div class="col-sm-9">
<div class="input-group">
{{ form_widget(form.price) }}
{{ form_widget(form.priceCurrency, {'attr': {'class': 'selectpicker', 'data-controller': 'elements--selectpicker'}}) }}
</div>
{{ form_errors(form.price) }}
{{ form_errors(form.priceCurrency) }}
</div>
</div>
{{ form_row(form.comment) }}
</div>
</td>
</tr>
{% endblock %}

View file

@ -41,7 +41,7 @@
<div class="input-group {% if sm %}input-group-sm{% endif %}">
{{ form_widget(form.value) }}
{% if form.prefix is defined %}
{{ form_widget(form.prefix, {'attr': {'class': 'form-select'}}) }}
{{ form_widget(form.prefix, {'attr': {'class': '', 'style': 'max-width: 40px;'}}) }}
{% endif %}
{% if unit is not empty %}
<label class="input-group-text">{{ unit }}</label>
@ -124,3 +124,7 @@
{{- block("choice_widget_collapsed", "bootstrap_base_layout.html.twig") -}}
{%- endblock choice_widget_collapsed -%}
{% block part_select_widget %}
{{ form_widget(form.autocomplete) }}
{% endblock %}

View file

@ -37,14 +37,20 @@
<td>{{ part.iD }}</td>
</tr>
<tr> {# ID #}
<tr> {# IPN #}
<td>{% trans %}part.edit.ipn{% endtrans %}</td>
<td>{{ part.ipn ?? 'part.ipn.not_defined'|trans }}</td>
</tr>
<tr> {# Favorite status #}
<td>{% trans %}part.isFavorite{% endtrans %}</td>
<td>{{ helper.boolean(part.favorite) }}</td>
<td>{{ helper.boolean_badge(part.favorite) }}</td>
</tr>
<tr> {# Build status #}
<td>{% trans %}part.is_build_part{% endtrans %}</td>
<td>{{ helper.boolean_badge(part.projectBuildPart) }}
{% if part.projectBuildPart %}(<a href="{{ entity_url(part.builtProject, "edit") }}">{{ part.builtProject.name }}</a>){% endif %}</td>
</tr>
<tr>

View file

@ -1,6 +1,8 @@
{% import "helper.twig" as helper %}
{% import "LabelSystem/dropdown_macro.html.twig" as dropdown %}
{% include "Parts/info/_withdraw_modal.html.twig" %}
<table class="table table-striped table-hover table-responsive-sm">
<thead>
<tr>
@ -8,6 +10,7 @@
<th>{% trans %}part_lots.storage_location{% endtrans %}</th>
<th>{% trans %}part_lots.amount{% endtrans %}</th>
<th></th> {# Tags row #}
<th></th>
<th></th> {# Button row #}
</tr>
</thead>
@ -57,6 +60,31 @@
{% endif %}
</h6>
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#withdraw-modal"
data-action="withdraw" data-lot-id="{{ lot.id }}" data-lot-amount="{{ lot.amount }}"
title="{% trans %}part.info.withdraw_modal.title.withdraw{% endtrans %}"
{% if not is_granted('withdraw', lot) or not withdraw_add_helper.canWithdraw(lot) %}disabled{% endif %}
>
<i class="fa-solid fa-minus fa-fw"></i>
</button>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#withdraw-modal"
data-action="add" data-lot-id="{{ lot.id }}" data-lot-amount="{{ lot.amount }}"
title="{% trans %}part.info.withdraw_modal.title.add{% endtrans %}"
{% if not is_granted('add', lot) or not withdraw_add_helper.canAdd(lot) %}disabled{% endif %}
>
<i class="fa-solid fa-plus fa-fw"></i>
</button>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#withdraw-modal"
data-action="move" data-lot-id="{{ lot.id }}" data-lot-amount="{{ lot.amount }}"
title="{% trans %}part.info.withdraw_modal.title.move{% endtrans %}"
{% if not is_granted('move', lot) or not withdraw_add_helper.canWithdraw(lot) or part.partLots.count == 1 %}disabled{% endif %}
>
<i class="fa-solid fa-right-left fa-fw"></i>
</button>
</div>
</td>
<td>
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
</td>

View file

@ -0,0 +1,35 @@
{% import "components/attachments.macro.html.twig" as attachments %}
{% import "helper.twig" as helper %}
<table class="table table-striped table-sm table-hover table-responsive-sm">
<thead>
<tr>
<th></th>
<th>{% trans %}entity.info.name{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th>{% trans %}project.bom.quantity{% endtrans %}</th>
<th>{% trans %}project.bom.mountnames{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for bom_entry in part.projectBomEntries %}
{# @var bom_entry App\Entity\Project\ProjectBOMEntry #}
<tr>
<td>{% if bom_entry.project.masterPictureAttachment is not null %}{{ attachments.attachment_icon(bom_entry.project.masterPictureAttachment, attachment_manager) }}{% endif %}</td>
<td><a href="{{ path('project_info', {'id': bom_entry.project.iD}) }}">{{ bom_entry.project.name }}</a></td> {# Name #}
<td>{{ bom_entry.project.description|format_markdown }}</td> {# Description #}
<td>{{ bom_entry.quantity | format_amount(part.partUnit) }}</td>
<td>{% for tag in bom_entry.mountnames|split(',') %}
<span class="badge bg-secondary badge-secondary" >{{ tag | trim }}</span>
{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-success" {% if not is_granted('@projects.edit') %}disabled{% endif %}
href="{{ path('project_add_parts_no_id', {"parts": part.id, "_redirect": app.request.requestUri}) }}">
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}part.info.add_part_to_project{% endtrans %}
</a>

View file

@ -4,6 +4,10 @@
<b class="mb-2">{% trans with {'%timestamp%': timeTravel|format_datetime('short')} %}part.info.timetravel_hint{% endtrans %}</b>
{% endif %}
{% if part.projectBuildPart %}
<b class="mb-2">{% trans %}part.info.projectBuildPart.hint{% endtrans %}: <a href="{{ entity_url(part.builtProject) }}">{{ part.builtProject.name }}</a></b>
{% endif %}
<div class="mb-3">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(part, true) }}

View file

@ -51,4 +51,10 @@
</div>
</form>
{{ dropdown.profile_dropdown('part', part.id) }}
{{ dropdown.profile_dropdown('part', part.id) }}
<a class="btn btn-success mt-2" {% if not is_granted('@projects.edit') %}disabled{% endif %}
href="{{ path('project_add_parts_no_id', {"parts": part.id, "_redirect": app.request.requestUri}) }}">
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}part.info.add_part_to_project{% endtrans %}
</a>

View file

@ -0,0 +1,61 @@
<div class="modal fade" id="withdraw-modal" tabindex="-1" aria-labelledby="withdraw-modal-title" aria-hidden="true" {{ stimulus_controller('pages/part_withdraw_modal') }}>
<form method="post" action="{{ path('part_add_withdraw', {"id": part.id}) }}">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="withdraw-modal-title"
data-withdraw="{% trans %}part.info.withdraw_modal.title.withdraw{% endtrans %}"
data-add="{% trans %}part.info.withdraw_modal.title.add{% endtrans %}"
data-move="{% trans %}part.info.withdraw_modal.title.move{% endtrans %}"
>Filled by JS Controller</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# non visible form elements #}
<input type="hidden" name="lot_id" value="">
<input type="hidden" name="action" value="">
<input type="hidden" name="_csfr" value="{{ csrf_token('part_withraw' ~ part.iD) }}">
<input type="hidden" name="_redirect" value="{{ app.request.requestUri }}">
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.withdraw_modal.amount{% endtrans %}
</label>
<div class="col-sm-9">
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="amount" value="">
</div>
</div>
<div class="row mb-2 d-none" id="withdraw-modal-move-to">
<label class="col-form-label col-sm-3">{% trans %}part.info.withdraw_modal.move_to{% endtrans %}</label>
<div class="col-sm-9">
{% for lots in part.partLots|filter(l => l.instockUnknown == false) %}
<div class="form-check">
<input class="form-check-input" type="radio" name="target_id" value="{{ lots.iD }}" id="modal_target_radio_{{ lots.iD }}" {% if not withdraw_add_helper.canAdd(lots) %}disabled{% endif %} required {% if loop.first %}checked{% endif %}>
<label class="form-check-label" for="modal_target_radio_{{ lots.iD }}">
{{ (lots.storageLocation) ? lots.storageLocation.fullPath : ("Lot " ~ loop.index) }}{% if lots.name is not empty %} ({{ lots.name }}){% endif %}: <b>{{ lots.amount | format_amount(part.partUnit) }}</b>
</label>
</div>
{% endfor %}
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="comment" value="">
<div id="emailHelp" class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
<button type="submit" class="btn btn-primary">{% trans %}modal.submit{% endtrans %}</button>
</div>
</div>
</div>
</form>
</div>

View file

@ -20,6 +20,9 @@
{% if timeTravel != null %}
<i>({{ timeTravel | format_datetime('short') }})</i>
{% endif %}
{% if part.projectBuildPart %}
(<i>{{ entity_type_label(part.builtProject) }}</i>: <a class="text-white" href="{{ entity_url(part.builtProject) }}">{{ part.builtProject.name }}</a>)
{% endif %}
<div class="float-end">
{% trans %}id.label{% endtrans %}: {{ part.id }} {% if part.ipn is not empty %}(<i>{{ part.ipn }}</i>){% endif %}
</div>
@ -91,6 +94,15 @@
{% trans %}vendor.partinfo.history{% endtrans %}
</a>
</li>
{% if part.projectBomEntries is not empty %}
<li class="nav-item">
<a class="nav-link" id="projects-tab" data-bs-toggle="tab" href="#projects" role="tab">
<i class="fas fa-archive fa-fw"></i>
{% trans %}project.labelp{% endtrans %}
<span class="badge bg-secondary">{{ part.projectBomEntries | length }}</span>
</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" id="tools-tab" data-bs-toggle="tab" href="#tools" role="tab">
<i class="fas fa-tools"></i>
@ -130,6 +142,10 @@
</div>
{% endif %}
<div class="tab-pane fade" id="projects" role="tabpanel" aria-labelledby="projects-tab">
{% include "Parts/info/_projects.html.twig" %}
</div>
<div class="tab-pane fade" id="history" role="tabpanel" aria-labelledby="history-tab">
{% include "Parts/info/_history.html.twig" %}
</div>

View file

@ -0,0 +1,22 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}project.add_parts_to_project{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}project.add_parts_to_project{% endtrans %}{% if project %}: <i>{{ project.name }}</i>{% endif %}
{% endblock %}
{% block card_content %}
{{ form_start(form) }}
{{ form_row(form.project) }}
{% form_theme form.bom_entries with ['Form/collection_types_layout.html.twig'] %}
{{ form_widget(form.bom_entries) }}
{{ form_row(form.submit) }}
{{ form_end(form) }}
{% endblock %}

View file

@ -0,0 +1,11 @@
{% import "components/datatables.macro.html.twig" as datatables %}
<div class="mb-2"></div>
{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'projects') }}
<a class="btn btn-success" {% if not is_granted('@projects.edit') %}disabled{% endif %}
href="{{ path('project_add_parts', {"id": project.id, "_redirect": app.request.requestUri}) }}">
<i class="fa-solid fa-square-plus fa-fw"></i>
{% trans %}project.info.bom_add_parts{% endtrans %}
</a>

View file

@ -0,0 +1,77 @@
{% import "helper.twig" as helper %}
<div class="row mt-2">
<div class="col-md-8">
<div class="row">
<div class="col-md-3 col-lg-4 col-4 mt-auto mb-auto">
{% if project.masterPictureAttachment %}
<a href="{{ entity_url(project.masterPictureAttachment, 'file_view') }}" data-turbo="false" target="_blank" rel="noopener">
<img class="d-block w-100 img-fluid img-thumbnail bg-light" src="{{ entity_url(project.masterPictureAttachment, 'file_view') }}" alt="">
</a>
{% else %}
<img src="{{ asset('img/part_placeholder.svg') }}" class="img-fluid img-thumbnail bg-light mb-2" alt="Part main image" height="300" width="300">
{% endif %}
</div>
<div class="col-md-9 col-lg-8 col-7">
<h3 class="w-fit" title="{% trans %}name.label{% endtrans %}">{{ project.name }}
{# You need edit permission to use the edit button #}
{% if is_granted('edit', project) %}
<a href="{{ entity_url(project, 'edit') }}"><i class="fas fa-fw fa-sm fa-edit"></i></a>
{% endif %}
</h3>
<h6 class="text-muted w-fit" title="{% trans %}description.label{% endtrans %}"><span>{{ project.description|format_markdown(true) }}</span></h6>
{% if project.buildPart %}
<h6>{% trans %}project.edit.associated_build_part{% endtrans %}:</h6>
<a href="{{ entity_url(project.buildPart) }}">{{ project.buildPart.name }}</a>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4"> {# Sidebar panel with infos about last creation date, etc. #}
<div class="mb-3">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(project, true) }}
</span>
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<i class="fas fa-calendar-plus fa-fw"></i> {{ helper.date_user_combination(project, false) }}
</span>
</div>
<div class="mt-1">
<h6>
{{ helper.project_status_to_badge(project.status) }}
</h6>
</div>
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-primary">
<i class="fa-solid fa-list-check fa-fw"></i>
{{ project.bomEntries | length }}
{% trans %}project.info.bom_entries_count{% endtrans %}
</span>
</h6>
</div>
{% if project.children is not empty %}
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-secondary">
<i class="fa-solid fa-folder-tree fa-fw"></i>
{{ project.children | length }}
{% trans %}project.info.sub_projects_count{% endtrans %}
</span>
</h6>
</div>
{% endif %}
</div>
{% if project.comment is not empty %}
<p>
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ project.comment|format_markdown }}
</p>
{% endif %}
</div>

View file

@ -0,0 +1,133 @@
{% import "helper.twig" as helper %}
{% import "LabelSystem/dropdown_macro.html.twig" as dropdown %}
{{ helper.breadcrumb_entity_link(project) }}
<div class="accordion mb-4" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#entityInfo" aria-expanded="true">
{% if project.masterPictureAttachment is not null and attachment_manager.isFileExisting(project.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-1 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity_url(project.masterPictureAttachment, 'file_view') }}" src="{{ attachment_thumbnail(project.masterPictureAttachment, 'thumbnail_sm') }}">
{% else %}
{{ helper.entity_icon(project, "me-1") }}
{% endif %}
{% trans %}project.label{% endtrans %}:&nbsp;<b>{{ project.name }}</b>
</button>
</div>
<div id="entityInfo" class="accordion-collapse collapse show" data-bs-parent="#listAccordion">
<div class="accordion-body">
{% if project.description is not empty %}
{{ project.description|format_markdown }}
{% endif %}
<div class="row">
<div class="col-sm-2">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<a class="nav-link active" id="v-pills-home-tab" data-bs-toggle="pill" href="#v-pills-home" role="tab" aria-controls="v-pills-home" aria-selected="true">
<i class="fas fa-info-circle fa-fw"></i>
{% trans %}entity.info.common.tab{% endtrans %}
</a>
<a class="nav-link" id="v-pills-statistics-tab" data-bs-toggle="pill" href="#v-pills-statistics" role="tab" aria-controls="v-pills-profile" aria-selected="false">
<i class="fas fa-chart-pie fa-fw"></i>
{% trans %}entity.info.statistics.tab{% endtrans %}
</a>
{% if project.attachments is not empty %}
<a class="nav-link" id="v-pills-attachments-tab" data-bs-toggle="pill" href="#v-pills-attachments" role="tab" aria-controls="v-pills-attachments" aria-selected="false">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}entity.info.attachments.tab{% endtrans %}
</a>
{% endif %}
{% if project.parameters is not empty %}
<a class="nav-link" id="v-pills-parameters-tab" data-bs-toggle="pill" href="#v-pills-parameters" role="tab" aria-controls="v-pills-parameters" aria-selected="false">
<i class="fas fa-atlas fa-fw"></i>
{% trans %}entity.info.parameters.tab{% endtrans %}
</a>
{% endif %}
{% if project.comment is not empty %}
<a class="nav-link" id="v-pills-comment-tab" data-bs-toggle="pill" href="#v-pills-comment" role="tab">
<i class="fas fa-comment-alt fa-fw"></i>
{% trans %}comment.label{% endtrans %}
</a>
{% endif %}
</div>
</div>
<div class="col-sm-10">
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
<div class="row">
<div class="col-sm-9 form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.name{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ project.name }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parent{% endtrans %}:</label>
<span class="col-sm form-control-static">
{% if project.parent %}
{{ project.parent.fullPath }}
{% else %}
-
{% endif %}
</span>
</div>
</div>
<div class="col-sm-3">
{% block quick_links %}{% endblock %}
<a class="btn btn-secondary w-100 mb-2" href="{{ entity_url(project, 'edit') }}">
<i class="fas fa-edit"></i> {% trans %}entity.edit.btn{% endtrans %}
</a>
<div class="">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ project.lastModified | format_datetime("short") }}
</span>
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<i class="fas fa-calendar-plus fa-fw"></i> {{ project.addedDate | format_datetime("short") }}
</span>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="v-pills-statistics" role="tabpanel" aria-labelledby="v-pills-statistics-tab">
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.children_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ project.children | length }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parts_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ project.bomEntries | length }}</span>
</div>
</div>
</div>
{% if project.attachments is not empty %}
<div class="tab-pane fade" id="v-pills-attachments" role="tabpanel" aria-labelledby="v-pills-attachments-tab">
{% include "Parts/info/_attachments_info.html.twig" with {"part": project} %}
</div>
{% endif %}
{% if project.parameters is not empty %}
<div class="tab-pane fade" id="v-pills-parameters" role="tabpanel" aria-labelledby="v-pills-parameters-tab">
{% for name, parameters in project.groupedParameters %}
{% if name is not empty %}<h5 class="mt-1">{{ name }}</h5>{% endif %}
{{ helper.parameters_table(project) }}
{% endfor %}
</div>
{% endif %}
{% if project.comment is not empty %}
<div class="tab-pane fade" id="v-pills-comment" role="tabpanel" aria-labelledby="home-tab">
<div class="container-fluid mt-2 latex" data-controller="common--latex">
{{ project.comment|format_markdown }}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,28 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th>{% trans %}name.label{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th># {% trans %}project.info.bom_entries_count{% endtrans %}</th>
<th># {% trans %}project.info.sub_projects_count{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for subproject in project.children %}
<tr>
<td> {# Name #}
<a href="{{ entity_url(subproject, 'info') }}">{{ subproject.name }}</a>
</td>
<td> {# Description #}
{{ subproject.description | format_markdown }}
</td>
<td>
{{ subproject.bomEntries | length }}
</td>
<td>
{{ subproject.children | length }}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,95 @@
{% extends "main_card.html.twig" %}
{% import "helper.twig" as helper %}
{% block title %}
{% trans %}project.info.title{% endtrans %}: {{ project.name }}
{% endblock %}
{% block content %}
{{ helper.breadcrumb_entity_link(project) }}
{{ parent() }}
{% endblock %}
{% block card_title %}
{% if project.masterPictureAttachment is not null and attachment_manager.isFileExisting(project.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-1 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity_url(project.masterPictureAttachment, 'file_view') }}" src="{{ attachment_thumbnail(project.masterPictureAttachment, 'thumbnail_sm') }}">
{% else %}
{{ helper.entity_icon(project, "me-1") }}
{% endif %}
{% trans %}project.info.title{% endtrans %}:&nbsp;<b>{{ project.name }}</b>
{% endblock %}
{% block card_content %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="info-tab" data-bs-toggle="tab" data-bs-target="#info-tab-pane"
type="button" role="tab" aria-controls="info-tab-pane" aria-selected="true">
<i class="fa-solid fa-circle-info fa-fw"></i>
{% trans %}project.info.info.label{% endtrans %}
</button>
</li>
{% if project.children is not empty %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="subprojects-tab" data-bs-toggle="tab" data-bs-target="#subprojects-tab-pane"
type="button" role="tab" aria-controls="subprojects-tab-pane" aria-selected="false">
<i class="fa-solid fa-folder-tree fa-fw"></i>
{% trans %}project.info.sub_projects.label{% endtrans %}
<span class="badge bg-secondary">{{ project.children | length }}</span>
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="bom-tab" data-bs-toggle="tab" data-bs-target="#bom-tab-pane"
type="button" role="tab" aria-controls="bom-tab-pane" aria-selected="false">
<i class="fa-solid fa-list-check fa-fw"></i>
{% trans %}project_bom_entry.label{% endtrans %}
<span class="badge bg-secondary">{{ project.bomEntries | length }}</span>
</button>
</li>
{% if project.attachments is not empty %}
<li class="nav-item">
<a class="nav-link" id="attachments-tab" data-bs-toggle="tab"
data-bs-target="#attachments-tab-pane" role="tab">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}attachment.labelp{% endtrans %}
<span class="badge bg-secondary">{{ project.attachments | length }}</span>
</a>
</li>
{% endif %}
{% if project.parameters is not empty %}
<li class="nav-item">
<a class="nav-link" id="parameters-tab" data-bs-toggle="tab"
data-bs-target="#parameters-tab-pane" role="tab">
<i class="fas fa-atlas fa-fw"></i>
{% trans %}entity.info.parameters.tab{% endtrans %}
<span class="badge bg-secondary">{{ project.parameters | length }}</span>
</a>
</li>
{% endif %}
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="info-tab-pane" role="tabpanel" aria-labelledby="info-tab" tabindex="0">
{% include "Projects/info/_info.html.twig" %}
</div>
{% if project.children is not empty %}
<div class="tab-pane fade" id="subprojects-tab-pane" role="tabpanel" aria-labelledby="bom-tab" tabindex="0">
{% include "Projects/info/_subprojects.html.twig" %}
</div>
{% endif %}
<div class="tab-pane fade" id="bom-tab-pane" role="tabpanel" aria-labelledby="bom-tab" tabindex="0">
{% include "Projects/info/_bom.html.twig" %}
</div>
<div class="tab-pane fade" id="attachments-tab-pane" role="tabpanel" aria-labelledby="attachments-tab" tabindex="0">
{% include "Parts/info/_attachments_info.html.twig" with {"part": project} %}
</div>
<div class="tab-pane fade" id="parameters-tab-pane" role="tabpanel" aria-labelledby="parameters-tab">
{% for name, parameters in project.groupedParameters %}
{% if name is not empty %}<h5 class="mt-1">{{ name }}</h5>{% endif %}
{{ helper.parameters_table(project.parameters) }}
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -1,12 +1,14 @@
{% macro controller(form, deleteMessage) %}
{% macro controller(form, deleteMessage, rowsToDelete) %}
{% if form.vars.prototype is defined %}
{{ stimulus_controller('elements/collection_type', {
'deleteMessage': deleteMessage|trans,
'prototype': form_widget(form.vars.prototype)|e('html_attr')
'prototype': form_widget(form.vars.prototype)|e('html_attr'),
'rowsToDelete': rowsToDelete,
}) }}
{% else %} {# If add_element is disabled/forbidden, prototype is not available #}
{{ stimulus_controller('elements/collection_type', {
'deleteMessage': deleteMessage|trans,
'rowsToDelete': rowsToDelete
}) }}
{% endif %}
{% endmacro %}

View file

@ -48,6 +48,9 @@
<option {% if not is_granted('@manufacturers.read') %}disabled{% endif %} value="change_manufacturer" data-url="{{ path('select_manufacturer') }}">{% trans %}part_list.action.action.change_manufacturer{% endtrans %}</option>
<option {% if not is_granted('@measurement_units.read') %}disabled{% endif %} value="change_unit" data-url="{{ path('select_measurement_unit') }}">{% trans %}part_list.action.action.change_unit{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.projects{% endtrans %}">
<option {% if not is_granted('@projects.read') %}disabled{% endif %} value="add_to_project" data-url="{{ path('select_project')}}">{% trans %}part_list.action.projects.add_to_project{% endtrans %}</option>
</optgroup>
<option {% if not is_granted('@parts.delete') %}disabled{% endif %} value="delete">{% trans %}part_list.action.action.delete{% endtrans %}</option>
</select>

View file

@ -6,7 +6,7 @@
['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read')],
['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read')],
['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read')],
['devices', path('tree_device_root'), 'device.labelp', is_granted('@devices.read')],
['devices', path('tree_device_root'), 'project.labelp', is_granted('@projects.read')],
['tools', path('tree_tools'), 'tools.label', true],
] %}

View file

@ -57,6 +57,21 @@
{% endif %}
{% endmacro %}
{% macro project_status_to_badge(status, class="badge") %}
{% if status is not empty %}
{% set color = " bg-secondary" %}
{% if status == "in_production" %}
{% set color = " bg-success" %}
{% endif %}
<span class="{{ class ~ color}}">
<i class="fa-fw fas fa-info-circle"></i>
{{ ("project.status." ~ status) | trans }}
</span>
{% endif %}
{% endmacro %}
{% macro structural_entity_link(entity, link_type = "list_parts") %}
{# @var entity \App\Entity\Base\StructuralDBElement #}
{% if entity %}
@ -81,7 +96,7 @@
"attachment_type": ["fa-solid fa-file-alt", "attachment_type.label"],
"category": ["fa-solid fa-tags", "category.label"],
"currency": ["fa-solid fa-coins", "currency.label"],
"device": ["fa-solid fa-archive", "device.label"],
"device": ["fa-solid fa-archive", "project.label"],
"footprint": ["fa-solid fa-microchip", "footprint.label"],
"group": ["fa-solid fa-users", "group.label"],
"label_profile": ["fa-solid fa-qrcode", "label_profile.label"],

View file

@ -126,5 +126,10 @@ class ApplicationAvailabilityFunctionalTest extends WebTestCase
//Webauthn Register
yield ['/webauthn/register'];
//Projects
yield ['/project/1/info'];
yield ['/project/1/add_parts'];
yield ['/project/1/add_parts?parts=1,2'];
}
}

View file

@ -92,7 +92,7 @@ abstract class AbstractAdminControllerTest extends WebTestCase
}
//Test read/list access by access /new overview page
$client->request('GET', static::$base_path.'/1');
$client->request('GET', static::$base_path.'/1/edit');
$this->assertFalse($client->getResponse()->isRedirect());
$this->assertSame($read, $client->getResponse()->isSuccessful(), 'Controller was not successful!');
$this->assertSame($read, !$client->getResponse()->isForbidden(), 'Permission Checking not working!');

View file

@ -23,14 +23,14 @@ declare(strict_types=1);
namespace App\Tests\Controller\AdminPages;
use App\Entity\Devices\Device;
use App\Entity\ProjectSystem\Project;
/**
* @group slow
* @group DB
*/
class DeviceControllerTest extends AbstractAdminControllerTest
class ProjectControllerTest extends AbstractAdminControllerTest
{
protected static $base_path = '/en'.'/device';
protected static $entity_class = Device::class;
protected static $base_path = '/en'.'/project';
protected static $entity_class = Project::class;
}

Some files were not shown because too many files have changed in this diff Show more