Merge tag 'v2.2.1' into Buerklin-provider
2
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/tests.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['8.2', '8.3', '8.4' ]
|
||||
php-versions: ['8.2', '8.3', '8.4', '8.5' ]
|
||||
db-type: [ 'mysql', 'sqlite', 'postgres' ]
|
||||
|
||||
env:
|
||||
|
|
@ -104,7 +104,7 @@ jobs:
|
|||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -48,3 +48,6 @@ yarn-error.log
|
|||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
91
Makefile
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# PartDB Makefile for Test Environment Management
|
||||
|
||||
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
|
||||
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
|
||||
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
|
||||
|
||||
# Default target
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# Dependencies
|
||||
deps-install: ## Install PHP dependencies with unlimited memory
|
||||
@echo "📦 Installing PHP dependencies..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer install
|
||||
yarn install
|
||||
@echo "✅ Dependencies installed"
|
||||
|
||||
# Complete test environment setup
|
||||
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
|
||||
@echo "✅ Test environment setup complete!"
|
||||
|
||||
# Clean test environment
|
||||
test-clean: ## Clean test cache and database files
|
||||
@echo "🧹 Cleaning test environment..."
|
||||
rm -rf var/cache/test
|
||||
rm -f var/app_test.db
|
||||
@echo "✅ Test environment cleaned"
|
||||
|
||||
# Create test database
|
||||
test-db-create: ## Create test database (if not exists)
|
||||
@echo "🗄️ Creating test database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
# Run database migrations for test environment
|
||||
test-db-migrate: ## Run database migrations for test environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
|
||||
|
||||
# Clear test cache
|
||||
test-cache-clear: ## Clear test cache
|
||||
@echo "🗑️ Clearing test cache..."
|
||||
rm -rf var/cache/test
|
||||
@echo "✅ Test cache cleared"
|
||||
|
||||
# Load test fixtures
|
||||
test-fixtures: ## Load test fixtures
|
||||
@echo "📦 Loading test fixtures..."
|
||||
php bin/console partdb:fixtures:load -n --env test
|
||||
|
||||
# Run PHPUnit tests
|
||||
test-run: ## Run PHPUnit tests
|
||||
@echo "🧪 Running tests..."
|
||||
php bin/phpunit
|
||||
|
||||
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
||||
test-reset: test-cache-clear test-db-migrate test-fixtures
|
||||
@echo "✅ Test environment reset complete!"
|
||||
|
||||
test-typecheck: ## Run static analysis (PHPStan)
|
||||
@echo "🧪 Running type checks..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||
|
||||
# Development helpers
|
||||
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
|
||||
@echo "✅ Development environment setup complete!"
|
||||
|
||||
dev-clean: ## Clean development cache and database files
|
||||
@echo "🧹 Cleaning development environment..."
|
||||
rm -rf var/cache/dev
|
||||
rm -f var/app_dev.db
|
||||
@echo "✅ Development environment cleaned"
|
||||
|
||||
dev-db-create: ## Create development database (if not exists)
|
||||
@echo "🗄️ Creating development database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
dev-db-migrate: ## Run database migrations for development environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
|
||||
|
||||
dev-cache-clear: ## Clear development cache
|
||||
@echo "🗑️ Clearing development cache..."
|
||||
rm -rf var/cache/dev
|
||||
@echo "✅ Development cache cleared"
|
||||
|
||||
dev-warmup: ## Warm up development cache
|
||||
@echo "🔥 Warming up development cache..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
|
||||
|
||||
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
|
||||
@echo "✅ Development environment reset complete!"
|
||||
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.1.2
|
||||
2.2.1
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@
|
|||
import {Plugin} from 'ckeditor5';
|
||||
|
||||
require('./lang/de.js');
|
||||
require('./lang/en.js');
|
||||
|
||||
import { addListToDropdown, createDropdown } from 'ckeditor5';
|
||||
|
||||
import {Collection} from 'ckeditor5';
|
||||
import {Model} from 'ckeditor5';
|
||||
import {UIModel} from 'ckeditor5';
|
||||
|
||||
export default class PartDBLabelUI extends Plugin {
|
||||
init() {
|
||||
|
|
@ -151,18 +152,28 @@ const PLACEHOLDERS = [
|
|||
function getDropdownItemsDefinitions(t) {
|
||||
const itemDefinitions = new Collection();
|
||||
|
||||
let first = true;
|
||||
|
||||
for ( const group of PLACEHOLDERS) {
|
||||
|
||||
//Add group header
|
||||
itemDefinitions.add({
|
||||
'type': 'separator',
|
||||
model: new Model( {
|
||||
withText: true,
|
||||
})
|
||||
});
|
||||
|
||||
//Skip separator for first group
|
||||
if (!first) {
|
||||
|
||||
itemDefinitions.add({
|
||||
'type': 'separator',
|
||||
model: new UIModel( {
|
||||
withText: true,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
first = false;
|
||||
}
|
||||
|
||||
itemDefinitions.add({
|
||||
type: 'button',
|
||||
model: new Model( {
|
||||
model: new UIModel( {
|
||||
label: t(group.label),
|
||||
withText: true,
|
||||
isEnabled: false,
|
||||
|
|
@ -173,7 +184,7 @@ function getDropdownItemsDefinitions(t) {
|
|||
for ( const entry of group.entries) {
|
||||
const definition = {
|
||||
type: 'button',
|
||||
model: new Model( {
|
||||
model: new UIModel( {
|
||||
commandParam: entry[0],
|
||||
label: t(entry[1]),
|
||||
tooltip: entry[0],
|
||||
|
|
|
|||
|
|
@ -17,15 +17,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Make sure that the global object is defined. If not, define it.
|
||||
window.CKEDITOR_TRANSLATIONS = window.CKEDITOR_TRANSLATIONS || {};
|
||||
import {add} from "ckeditor5";
|
||||
|
||||
// Make sure that the dictionary for Polish translations exist.
|
||||
window.CKEDITOR_TRANSLATIONS[ 'de' ] = window.CKEDITOR_TRANSLATIONS[ 'de' ] || {};
|
||||
window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary = window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary || {};
|
||||
|
||||
// Extend the dictionary for Polish translations with your translations:
|
||||
Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
|
||||
add( "de", {
|
||||
'Label Placeholder': 'Label Platzhalter',
|
||||
'Part': 'Bauteil',
|
||||
|
||||
|
|
@ -88,5 +82,4 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
|
|||
'Instance name': 'Instanzname',
|
||||
'Target type': 'Zieltyp',
|
||||
'URL of this Part-DB instance': 'URL dieser Part-DB Instanz',
|
||||
|
||||
} );
|
||||
});
|
||||
|
|
|
|||
84
assets/ckeditor/plugins/PartDBLabel/lang/en.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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/>.
|
||||
*/
|
||||
|
||||
import {add} from "ckeditor5";
|
||||
|
||||
add( "en", {
|
||||
'Label Placeholder': 'Label placeholder',
|
||||
'Part': 'Part',
|
||||
|
||||
'Database ID': 'Database ID',
|
||||
'Part name': 'Part name',
|
||||
'Category': 'Category',
|
||||
'Category (Full path)': 'Category (full path)',
|
||||
'Manufacturer': 'Manufacturer',
|
||||
'Manufacturer (Full path)': 'Manufacturer (full path)',
|
||||
'Footprint': 'Footprint',
|
||||
'Footprint (Full path)': 'Footprint (full path)',
|
||||
'Mass': 'Mass',
|
||||
'Manufacturer Product Number (MPN)': 'Manufacturer Product Number (MPN)',
|
||||
'Internal Part Number (IPN)': 'Internal Part Number (IPN)',
|
||||
'Tags': 'Tags',
|
||||
'Manufacturing status': 'Manufacturing status',
|
||||
'Description': 'Description',
|
||||
'Description (plain text)': 'Description (plain text)',
|
||||
'Comment': 'Comment',
|
||||
'Comment (plain text)': 'Comment (plain text)',
|
||||
'Last modified datetime': 'Last modified datetime',
|
||||
'Creation datetime': 'Creation datetime',
|
||||
'IPN as QR code': 'IPN as QR code',
|
||||
'IPN as Code 128 barcode': 'IPN as Code 128 barcode',
|
||||
'IPN as Code 39 barcode': 'IPN as Code 39 barcode',
|
||||
|
||||
'Lot ID': 'Lot ID',
|
||||
'Lot name': 'Lot name',
|
||||
'Lot comment': 'Lot comment',
|
||||
'Lot expiration date': 'Lot expiration date',
|
||||
'Lot amount': 'Lot amount',
|
||||
'Storage location': 'Storage location',
|
||||
'Storage location (Full path)': 'Storage location (full path)',
|
||||
'Full name of the lot owner': 'Full name of the lot owner',
|
||||
'Username of the lot owner': 'Username of the lot owner',
|
||||
|
||||
'Barcodes': 'Barcodes',
|
||||
'Content of the 1D barcodes (like Code 39)': 'Content of the 1D barcodes (like Code 39)',
|
||||
'Content of the 2D barcodes (QR codes)': 'Content of the 2D barcodes (QR codes)',
|
||||
'QR code linking to this element': 'QR code linking to this element',
|
||||
'Code 128 barcode linking to this element': 'Code 128 barcode linking to this element',
|
||||
'Code 39 barcode linking to this element': 'Code 39 barcode linking to this element',
|
||||
'Code 93 barcode linking to this element': 'Code 93 barcode linking to this element',
|
||||
'Datamatrix code linking to this element': 'Datamatrix code linking to this element',
|
||||
|
||||
'Location ID': 'Location ID',
|
||||
'Name': 'Name',
|
||||
'Full path': 'Full path',
|
||||
'Parent name': 'Parent name',
|
||||
'Parent full path': 'Parent full path',
|
||||
'Full name of the location owner': 'Full name of the location owner',
|
||||
'Username of the location owner': 'Username of the location owner',
|
||||
|
||||
'Username': 'Username',
|
||||
'Username (including name)': 'Username (including name)',
|
||||
'Current datetime': 'Current datetime',
|
||||
'Current date': 'Current date',
|
||||
'Current time': 'Current time',
|
||||
'Instance name': 'Instance name',
|
||||
'Target type': 'Target type',
|
||||
'URL of this Part-DB instance': 'URL of this Part-DB instance',
|
||||
} );
|
||||
359
assets/controllers/bulk_import_controller.js
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import { generateCsrfHeaders } from "./csrf_protection_controller"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["progressBar", "progressText"]
|
||||
static values = {
|
||||
jobId: Number,
|
||||
partId: Number,
|
||||
researchUrl: String,
|
||||
researchAllUrl: String,
|
||||
markCompletedUrl: String,
|
||||
markSkippedUrl: String,
|
||||
markPendingUrl: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Auto-refresh progress if job is in progress
|
||||
if (this.hasProgressBarTarget) {
|
||||
this.startProgressUpdates()
|
||||
}
|
||||
|
||||
// Restore scroll position after page reload (if any)
|
||||
this.restoreScrollPosition()
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
|
||||
// Add CSRF headers if available
|
||||
const form = document.querySelector('form')
|
||||
if (form) {
|
||||
const csrfHeaders = generateCsrfHeaders(form)
|
||||
Object.assign(headers, csrfHeaders)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async fetchWithErrorHandling(url, options = {}, timeout = 30000) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...this.getHeaders(), ...options.headers },
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Server error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request timed out. Please try again.')
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error('Network error. Please check your connection and try again.')
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.progressInterval) {
|
||||
clearInterval(this.progressInterval)
|
||||
}
|
||||
}
|
||||
|
||||
startProgressUpdates() {
|
||||
// Progress updates are handled via page reload for better reliability
|
||||
// No need for periodic updates since state changes trigger page refresh
|
||||
}
|
||||
|
||||
restoreScrollPosition() {
|
||||
const savedPosition = sessionStorage.getItem('bulkImportScrollPosition')
|
||||
if (savedPosition) {
|
||||
// Restore scroll position after a small delay to ensure page is fully loaded
|
||||
setTimeout(() => {
|
||||
window.scrollTo(0, parseInt(savedPosition))
|
||||
// Clear the saved position so it doesn't interfere with normal navigation
|
||||
sessionStorage.removeItem('bulkImportScrollPosition')
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
async markCompleted(event) {
|
||||
const partId = event.currentTarget.dataset.partId
|
||||
|
||||
try {
|
||||
const url = this.markCompletedUrlValue.replace('__PART_ID__', partId)
|
||||
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
|
||||
|
||||
if (data.success) {
|
||||
this.updateProgressDisplay(data)
|
||||
this.markRowAsCompleted(partId)
|
||||
|
||||
if (data.job_completed) {
|
||||
this.showJobCompletedMessage()
|
||||
}
|
||||
} else {
|
||||
this.showErrorMessage(data.error || 'Failed to mark part as completed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking part as completed:', error)
|
||||
this.showErrorMessage(error.message || 'Failed to mark part as completed')
|
||||
}
|
||||
}
|
||||
|
||||
async markSkipped(event) {
|
||||
const partId = event.currentTarget.dataset.partId
|
||||
const reason = prompt('Reason for skipping (optional):') || ''
|
||||
|
||||
try {
|
||||
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
|
||||
const data = await this.fetchWithErrorHandling(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason })
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
this.updateProgressDisplay(data)
|
||||
this.markRowAsSkipped(partId)
|
||||
} else {
|
||||
this.showErrorMessage(data.error || 'Failed to mark part as skipped')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking part as skipped:', error)
|
||||
this.showErrorMessage(error.message || 'Failed to mark part as skipped')
|
||||
}
|
||||
}
|
||||
|
||||
async markPending(event) {
|
||||
const partId = event.currentTarget.dataset.partId
|
||||
|
||||
try {
|
||||
const url = this.markPendingUrlValue.replace('__PART_ID__', partId)
|
||||
const data = await this.fetchWithErrorHandling(url, { method: 'POST' })
|
||||
|
||||
if (data.success) {
|
||||
this.updateProgressDisplay(data)
|
||||
this.markRowAsPending(partId)
|
||||
} else {
|
||||
this.showErrorMessage(data.error || 'Failed to mark part as pending')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking part as pending:', error)
|
||||
this.showErrorMessage(error.message || 'Failed to mark part as pending')
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressDisplay(data) {
|
||||
if (this.hasProgressBarTarget) {
|
||||
this.progressBarTarget.style.width = `${data.progress}%`
|
||||
this.progressBarTarget.setAttribute('aria-valuenow', data.progress)
|
||||
}
|
||||
|
||||
if (this.hasProgressTextTarget) {
|
||||
this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed`
|
||||
}
|
||||
}
|
||||
|
||||
markRowAsCompleted(partId) {
|
||||
// Save scroll position and refresh page to show updated state
|
||||
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
markRowAsSkipped(partId) {
|
||||
// Save scroll position and refresh page to show updated state
|
||||
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
markRowAsPending(partId) {
|
||||
// Save scroll position and refresh page to show updated state
|
||||
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
showJobCompletedMessage() {
|
||||
const alert = document.createElement('div')
|
||||
alert.className = 'alert alert-success alert-dismissible fade show'
|
||||
alert.innerHTML = `
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Job completed! All parts have been processed.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`
|
||||
|
||||
const container = document.querySelector('.card-body')
|
||||
container.insertBefore(alert, container.firstChild)
|
||||
}
|
||||
|
||||
async researchPart(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const partId = event.currentTarget.dataset.partId
|
||||
const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`)
|
||||
const button = event.currentTarget
|
||||
|
||||
// Show loading state
|
||||
if (spinner) {
|
||||
spinner.style.display = 'inline-block'
|
||||
}
|
||||
button.disabled = true
|
||||
|
||||
try {
|
||||
const url = this.researchUrlValue.replace('__PART_ID__', partId)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Server error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`)
|
||||
// Save scroll position and reload to show updated results
|
||||
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
||||
window.location.reload()
|
||||
} else {
|
||||
this.showErrorMessage(data.error || 'Research failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error researching part:', error)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
this.showErrorMessage('Research timed out. Please try again.')
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
this.showErrorMessage('Network error. Please check your connection and try again.')
|
||||
} else {
|
||||
this.showErrorMessage(error.message || 'Research failed due to an unexpected error')
|
||||
}
|
||||
} finally {
|
||||
// Hide loading state
|
||||
if (spinner) {
|
||||
spinner.style.display = 'none'
|
||||
}
|
||||
button.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async researchAllParts(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const spinner = document.getElementById('research-all-spinner')
|
||||
const button = event.currentTarget
|
||||
|
||||
// Show loading state
|
||||
if (spinner) {
|
||||
spinner.style.display = 'inline-block'
|
||||
}
|
||||
button.disabled = true
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations
|
||||
|
||||
const response = await fetch(this.researchAllUrlValue, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Server error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`)
|
||||
// Save scroll position and reload to show updated results
|
||||
sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
|
||||
window.location.reload()
|
||||
} else {
|
||||
this.showErrorMessage(data.error || 'Bulk research failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error researching all parts:', error)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.')
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
this.showErrorMessage('Network error. Please check your connection and try again.')
|
||||
} else {
|
||||
this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error')
|
||||
}
|
||||
} finally {
|
||||
// Hide loading state
|
||||
if (spinner) {
|
||||
spinner.style.display = 'none'
|
||||
}
|
||||
button.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
showSuccessMessage(message) {
|
||||
this.showToast('success', message)
|
||||
}
|
||||
|
||||
showErrorMessage(message) {
|
||||
this.showToast('error', message)
|
||||
}
|
||||
|
||||
showToast(type, message) {
|
||||
// Create a simple alert that doesn't disrupt layout
|
||||
const alertId = 'alert-' + Date.now()
|
||||
const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle'
|
||||
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger'
|
||||
|
||||
const alertHTML = `
|
||||
<div class="alert ${alertClass} alert-dismissible fade show position-fixed"
|
||||
style="top: 20px; right: 20px; z-index: 9999; max-width: 400px;"
|
||||
id="${alertId}">
|
||||
<i class="fas ${iconClass} me-2"></i>
|
||||
${message}
|
||||
<button type="button" class="btn-close" onclick="this.parentElement.remove()" aria-label="Close"></button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add alert to body
|
||||
document.body.insertAdjacentHTML('beforeend', alertHTML)
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
const alertElement = document.getElementById(alertId)
|
||||
if (alertElement) {
|
||||
alertElement.remove()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
92
assets/controllers/bulk_job_manage_controller.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import { generateCsrfHeaders } from "./csrf_protection_controller"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
deleteUrl: String,
|
||||
stopUrl: String,
|
||||
deleteConfirmMessage: String,
|
||||
stopConfirmMessage: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Controller initialized
|
||||
}
|
||||
getHeaders() {
|
||||
const headers = {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
|
||||
// Add CSRF headers if available
|
||||
const form = document.querySelector('form')
|
||||
if (form) {
|
||||
const csrfHeaders = generateCsrfHeaders(form)
|
||||
Object.assign(headers, csrfHeaders)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
async deleteJob(event) {
|
||||
const jobId = event.currentTarget.dataset.jobId
|
||||
const confirmMessage = this.deleteConfirmMessageValue || 'Are you sure you want to delete this job?'
|
||||
|
||||
if (confirm(confirmMessage)) {
|
||||
try {
|
||||
const deleteUrl = this.deleteUrlValue.replace('__JOB_ID__', jobId)
|
||||
|
||||
const response = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
location.reload()
|
||||
} else {
|
||||
alert('Error deleting job: ' + (data.error || 'Unknown error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting job:', error)
|
||||
alert('Error deleting job: ' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopJob(event) {
|
||||
const jobId = event.currentTarget.dataset.jobId
|
||||
const confirmMessage = this.stopConfirmMessageValue || 'Are you sure you want to stop this job?'
|
||||
|
||||
if (confirm(confirmMessage)) {
|
||||
try {
|
||||
const stopUrl = this.stopUrlValue.replace('__JOB_ID__', jobId)
|
||||
|
||||
const response = await fetch(stopUrl, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
location.reload()
|
||||
} else {
|
||||
alert('Error stopping job: ' + (data.error || 'Unknown error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping job:', error)
|
||||
alert('Error stopping job: ' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ export default class extends Controller {
|
|||
|
||||
connect() {
|
||||
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
let settings = {
|
||||
persistent: false,
|
||||
create: true,
|
||||
|
|
@ -42,7 +47,7 @@ export default class extends Controller {
|
|||
selectOnTab: true,
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
return '<span>' + escape(data.label) + '</span>';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,27 @@ import {EditorWatchdog} from 'ckeditor5';
|
|||
import "ckeditor5/ckeditor5.css";;
|
||||
import "../../css/components/ckeditor.css";
|
||||
|
||||
const translationContext = require.context(
|
||||
'ckeditor5/translations',
|
||||
false,
|
||||
//Only load the translation files we will really need
|
||||
/(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/
|
||||
);
|
||||
|
||||
function loadTranslation(language) {
|
||||
if (!language || language === 'en') {
|
||||
return null;
|
||||
}
|
||||
const lang = language.slice(0, 2);
|
||||
const path = `./${lang}.js`;
|
||||
if (translationContext.keys().includes(path)) {
|
||||
const module = translationContext(path);
|
||||
return module.default;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
|
|
@ -63,6 +84,13 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
//Load translations if not english
|
||||
let translations = loadTranslation(language);
|
||||
if (translations) {
|
||||
//Keep existing translations (e.g. from other plugins), if any
|
||||
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
|
||||
}
|
||||
|
||||
const watchdog = new EditorWatchdog();
|
||||
watchdog.setCreator((elementOrData, editorConfig) => {
|
||||
return EDITOR_TYPE.create(elementOrData, editorConfig)
|
||||
|
|
|
|||
|
|
@ -10,13 +10,19 @@ export default class extends Controller {
|
|||
|
||||
connect() {
|
||||
|
||||
//Check if tomselect is inside an modal and do not attach the dropdown to body in that case (as it breaks the modal)
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
let settings = {
|
||||
allowEmptyOption: true,
|
||||
plugins: ['dropdown_input'],
|
||||
searchField: ["name", "description", "category", "footprint"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
|
|
|
|||
|
|
@ -38,13 +38,17 @@ export default class extends Controller {
|
|||
this._emptyMessage = this.element.getAttribute('title');
|
||||
}
|
||||
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
let settings = {
|
||||
plugins: ["clear_button"],
|
||||
allowEmptyOption: true,
|
||||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
|
||||
render: {
|
||||
item: this.renderItem.bind(this),
|
||||
|
|
|
|||
|
|
@ -26,10 +26,15 @@ export default class extends Controller {
|
|||
_tomSelect;
|
||||
|
||||
connect() {
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
this._tomSelect = new TomSelect(this.element, {
|
||||
maxItems: 1000,
|
||||
allowEmptyOption: true,
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
plugins: ['remove_button'],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ export default class extends Controller {
|
|||
|
||||
connect() {
|
||||
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
let settings = {
|
||||
persistent: false,
|
||||
create: true,
|
||||
|
|
@ -50,7 +55,7 @@ export default class extends Controller {
|
|||
valueField: 'text',
|
||||
searchField: 'text',
|
||||
orderField: 'text',
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ export default class extends Controller {
|
|||
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
|
||||
const addHint = this.element.getAttribute("data-add-hint") ?? "";
|
||||
|
||||
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
|
||||
let settings = {
|
||||
|
|
@ -54,7 +57,7 @@ export default class extends Controller {
|
|||
maxItems: 1,
|
||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||
splitOn: null,
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
|
||||
searchField: [
|
||||
{field: "text", weight : 2},
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ export default class extends Controller {
|
|||
_tomSelect;
|
||||
|
||||
connect() {
|
||||
let dropdownParent = "body";
|
||||
if (this.element.closest('.modal')) {
|
||||
dropdownParent = null
|
||||
}
|
||||
|
||||
let settings = {
|
||||
plugins: {
|
||||
remove_button:{},
|
||||
|
|
@ -43,7 +48,7 @@ export default class extends Controller {
|
|||
selectOnTab: true,
|
||||
createOnBlur: true,
|
||||
create: true,
|
||||
dropdownParent: 'body',
|
||||
dropdownParent: dropdownParent,
|
||||
};
|
||||
|
||||
if(this.element.dataset.autocomplete) {
|
||||
|
|
|
|||
136
assets/controllers/field_mapping_controller.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["tbody", "addButton", "submitButton"]
|
||||
static values = {
|
||||
mappingIndex: Number,
|
||||
maxMappings: Number,
|
||||
prototype: String,
|
||||
maxMappingsReachedMessage: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.updateAddButtonState()
|
||||
this.updateFieldOptions()
|
||||
this.attachEventListeners()
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
// Add event listeners to existing field selects
|
||||
const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
|
||||
fieldSelects.forEach(select => {
|
||||
select.addEventListener('change', this.updateFieldOptions.bind(this))
|
||||
})
|
||||
|
||||
// Note: Add button click is handled by Stimulus action in template (data-action="click->field-mapping#addMapping")
|
||||
// No manual event listener needed
|
||||
|
||||
// Form submit handler
|
||||
const form = this.element.querySelector('form')
|
||||
if (form && this.hasSubmitButtonTarget) {
|
||||
form.addEventListener('submit', this.handleFormSubmit.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
addMapping() {
|
||||
const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
|
||||
|
||||
if (currentMappings >= this.maxMappingsValue) {
|
||||
alert(this.maxMappingsReachedMessageValue)
|
||||
return
|
||||
}
|
||||
|
||||
const newRowHtml = this.prototypeValue.replace(/__name__/g, this.mappingIndexValue)
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = newRowHtml
|
||||
|
||||
const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0]
|
||||
const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1]
|
||||
const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2]
|
||||
|
||||
const newRow = document.createElement('tr')
|
||||
newRow.className = 'mapping-row'
|
||||
newRow.innerHTML = `
|
||||
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
|
||||
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
|
||||
<td>${priorityWidget ? priorityWidget.outerHTML : ''}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-action="click->field-mapping#removeMapping">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`
|
||||
|
||||
this.tbodyTarget.appendChild(newRow)
|
||||
this.mappingIndexValue++
|
||||
|
||||
const newFieldSelect = newRow.querySelector('select[name*="[field]"]')
|
||||
if (newFieldSelect) {
|
||||
newFieldSelect.value = ''
|
||||
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
|
||||
}
|
||||
|
||||
this.updateFieldOptions()
|
||||
this.updateAddButtonState()
|
||||
}
|
||||
|
||||
removeMapping(event) {
|
||||
const row = event.target.closest('tr')
|
||||
row.remove()
|
||||
this.updateFieldOptions()
|
||||
this.updateAddButtonState()
|
||||
}
|
||||
|
||||
updateFieldOptions() {
|
||||
const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]')
|
||||
|
||||
const selectedFields = Array.from(fieldSelects)
|
||||
.map(select => select.value)
|
||||
.filter(value => value && value !== '')
|
||||
|
||||
fieldSelects.forEach(select => {
|
||||
Array.from(select.options).forEach(option => {
|
||||
const isCurrentValue = option.value === select.value
|
||||
const isEmptyOption = !option.value || option.value === ''
|
||||
const isAlreadySelected = selectedFields.includes(option.value)
|
||||
|
||||
if (!isEmptyOption && isAlreadySelected && !isCurrentValue) {
|
||||
option.disabled = true
|
||||
option.style.display = 'none'
|
||||
} else {
|
||||
option.disabled = false
|
||||
option.style.display = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateAddButtonState() {
|
||||
const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length
|
||||
|
||||
if (this.hasAddButtonTarget) {
|
||||
if (currentMappings >= this.maxMappingsValue) {
|
||||
this.addButtonTarget.disabled = true
|
||||
this.addButtonTarget.title = this.maxMappingsReachedMessageValue
|
||||
} else {
|
||||
this.addButtonTarget.disabled = false
|
||||
this.addButtonTarget.title = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleFormSubmit(event) {
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.disabled = true
|
||||
|
||||
// Disable the entire form to prevent changes during processing
|
||||
const form = event.target
|
||||
const formElements = form.querySelectorAll('input, select, textarea, button')
|
||||
formElements.forEach(element => {
|
||||
if (element !== this.submitButtonTarget) {
|
||||
element.disabled = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,11 @@ th.select-checkbox {
|
|||
display: inline-flex;
|
||||
}
|
||||
|
||||
/** Add spacing between column visibility button and length menu */
|
||||
.buttons-colvis {
|
||||
margin-right: 0.2em !important;
|
||||
}
|
||||
|
||||
/** Fix datatables select-checkbox position */
|
||||
table.dataTable tr.selected td.select-checkbox:after
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@
|
|||
"doctrine/doctrine-bundle": "^2.0",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
"doctrine/orm": "^3.2.0",
|
||||
"dompdf/dompdf": "^v3.0.0",
|
||||
"part-db/swap-bundle": "^6.0.0",
|
||||
"dompdf/dompdf": "^3.1.2",
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"hshn/base64-encoded-file": "^5.0",
|
||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||
|
|
@ -37,6 +36,7 @@
|
|||
"league/csv": "^9.8.0",
|
||||
"league/html-to-markdown": "^5.0.1",
|
||||
"liip/imagine-bundle": "^2.2",
|
||||
"maennchen/zipstream-php": "2.1",
|
||||
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
|
||||
"nelexa/zip": "^4.0",
|
||||
"nelmio/cors-bundle": "^2.3",
|
||||
|
|
@ -45,6 +45,8 @@
|
|||
"omines/datatables-bundle": "^0.10.0",
|
||||
"paragonie/sodium_compat": "^1.21",
|
||||
"part-db/label-fonts": "^1.0",
|
||||
"part-db/swap-bundle": "^6.0.0",
|
||||
"phpoffice/phpspreadsheet": "^5.0.0",
|
||||
"rhukster/dom-sanitizer": "^1.0",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"s9e/text-formatter": "^2.1",
|
||||
|
|
@ -157,7 +159,7 @@
|
|||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
|
||||
"phpstan": "php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
|
|
|
|||
1618
composer.lock
generated
33
config/packages/doctrine.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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);
|
||||
|
||||
/**
|
||||
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
|
||||
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
|
||||
*/
|
||||
|
||||
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
|
||||
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
|
||||
if (PHP_VERSION_ID >= 80400) {
|
||||
$doctrine->orm()->enableNativeLazyObjects(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -20,12 +20,6 @@ nelmio_security:
|
|||
- 'digikey.com'
|
||||
- 'nexar.com'
|
||||
|
||||
# forces Microsoft's XSS-Protection with
|
||||
# its block mode
|
||||
xss_protection:
|
||||
enabled: true
|
||||
mode_block: true
|
||||
|
||||
# Send a full URL in the `Referer` header when performing a same-origin request,
|
||||
# only send the origin of the document to secure destination (HTTPS->HTTPS),
|
||||
# and send no header to a less secure destination (HTTPS->HTTP).
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ parameters:
|
|||
|
||||
# This is used as workaround for places where we can not access the settings directly (like the 2FA application names)
|
||||
partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage)
|
||||
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] # The languages that are shown in user drop down menu
|
||||
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl', 'hu'] # The languages that are shown in user drop down menu
|
||||
|
||||
partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails
|
||||
|
||||
|
|
@ -104,3 +104,9 @@ parameters:
|
|||
env(SAML_ROLE_MAPPING): '{}'
|
||||
|
||||
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
||||
|
||||
######################################################################################################################
|
||||
# Bulk Info Provider Import Configuration
|
||||
######################################################################################################################
|
||||
partdb.bulk_import.batch_size: 20 # Number of parts to process in each batch during bulk operations
|
||||
partdb.bulk_import.max_parts_per_operation: 1000 # Maximum number of parts allowed per bulk import operation
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status
|
||||
BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;;
|
||||
BC557;PNP transistor;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
|
||||
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
|
||||
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint
|
||||
"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric
|
||||
"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric
|
||||
"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123
|
||||
BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
|
||||
BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
|
||||
Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical
|
||||
|
|
|
|||
|
|
|
@ -48,14 +48,15 @@ The upgrade process works very similar to a normal (minor release) upgrade.
|
|||
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and `.env.local` file.
|
||||
The `php bin/console partdb:backup` command can help you with this.
|
||||
2. Pull the v2 version. For git installation you can do this with `git checkout v2.0.0` (or newer version)
|
||||
3. Run `composer install --no-dev -o` to update the dependencies.
|
||||
4. Run `yarn install` and `yarn build` to update the frontend assets.
|
||||
5. Rund `php bin/console doctrine:migrations:migrate` to update the database schema.
|
||||
6. Clear the cache with `php bin/console cache:clear`.
|
||||
7. Open your Part-DB instance in the browser and log in as an admin user.
|
||||
8. Go to the user or group permissions page, and give yourself (and other administrators) the right to change system settings (under "System" and "Configuration").
|
||||
9. You can now go to the settings page (under "System" and "Settings") and check if all settings are correct.
|
||||
10. Parameters which were previously set via environment variables are greyed out and cannot be changed in the web interface.
|
||||
3. Remove the `var/cache/` directory inside the Part-DB installation to ensure that no old cache files remain.
|
||||
4. Run `composer install --no-dev -o` to update the dependencies.
|
||||
5. Run `yarn install` and `yarn build` to update the frontend assets.
|
||||
6. Rund `php bin/console doctrine:migrations:migrate` to update the database schema.
|
||||
7. Clear the cache with `php bin/console cache:clear`.
|
||||
8. Open your Part-DB instance in the browser and log in as an admin user.
|
||||
9. Go to the user or group permissions page, and give yourself (and other administrators) the right to change system settings (under "System" and "Configuration").
|
||||
10. You can now go to the settings page (under "System" and "Settings") and check if all settings are correct.
|
||||
11. Parameters which were previously set via environment variables are greyed out and cannot be changed in the web interface.
|
||||
If you want to change them, you must migrate them to the settings interface as described below.
|
||||
|
||||
### Docker installation
|
||||
|
|
@ -87,3 +88,15 @@ After the migration run successfully, the contents of your environment variables
|
|||
Go through the environment variables listed by the command and remove them from your environment variable configuration (e.g. `.env.local` file or docker compose file), or just comment them out for now.
|
||||
|
||||
If you want to keep some environment variables, just leave them as they are, they will still work as before, the migration command only affects the settings stored in the database.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### cache:clear fails: You have requested a non-existent parameter "jbtronics.settings.proxy_dir".
|
||||
If you receive an error like
|
||||
```
|
||||
In App_KernelProdContainer.php line 2839:
|
||||
You have requested a non-existent parameter "jbtronics.settings.proxy_dir".
|
||||
```
|
||||
when running `php bin/console cache:clear` or `composer install`. You have to manually delete the `var/cache/`
|
||||
directory inside your Part-DB installation and try again.
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ You can select between the following export formats:
|
|||
efficiently.
|
||||
* **YAML** (Yet Another Markup Language): Very similar to JSON
|
||||
* **XML** (Extensible Markup Language): Good support with nested data structures. Similar use cases as JSON and YAML.
|
||||
* **Excel**: Similar to CSV, but in a native Excel format. Can be opened in Excel and LibreOffice Calc. Does not support nested
|
||||
data structures or sub-data (like parameters, attachments, etc.), very well (many columns are generated, as every
|
||||
possible sub-data is exported as a separate column).
|
||||
|
||||
Also, you can select between the following export levels:
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,13 @@ If you already have attachment types for images and datasheets and want the info
|
|||
can
|
||||
add the alternative names "Datasheet" and "Image" to the alternative names field of the attachment types.
|
||||
|
||||
## Bulk import
|
||||
|
||||
If you want to update the information of multiple parts, you can use the bulk import system: Go to a part table and select
|
||||
the parts you want to update. In the bulk actions dropdown select "Bulk info provider import" and click "Apply".
|
||||
You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the
|
||||
results will be shown.
|
||||
|
||||
## Data providers
|
||||
|
||||
The system tries to be as flexible as possible, so many different information sources can be used.
|
||||
|
|
|
|||
81
makefile
|
|
@ -1,112 +1,91 @@
|
|||
# PartDB Makefile for Test Environment Management
|
||||
|
||||
.PHONY: help test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset deps-install
|
||||
.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \
|
||||
test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \
|
||||
section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "PartDB Test Environment Management"
|
||||
@echo "=================================="
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " deps-install - Install PHP dependencies with unlimited memory"
|
||||
@echo ""
|
||||
@echo "Development Environment:"
|
||||
@echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)"
|
||||
@echo " dev-clean - Clean development cache and database files"
|
||||
@echo " dev-db-create - Create development database (if not exists)"
|
||||
@echo " dev-db-migrate - Run database migrations for development environment"
|
||||
@echo " dev-cache-clear - Clear development cache"
|
||||
@echo " dev-warmup - Warm up development cache"
|
||||
@echo " dev-reset - Quick development reset (clean + migrate)"
|
||||
@echo ""
|
||||
@echo "Test Environment:"
|
||||
@echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)"
|
||||
@echo " test-clean - Clean test cache and database files"
|
||||
@echo " test-db-create - Create test database (if not exists)"
|
||||
@echo " test-db-migrate - Run database migrations for test environment"
|
||||
@echo " test-cache-clear- Clear test cache"
|
||||
@echo " test-fixtures - Load test fixtures"
|
||||
@echo " test-run - Run PHPUnit tests"
|
||||
@echo ""
|
||||
@echo " help - Show this help message"
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# Install PHP dependencies with unlimited memory
|
||||
deps-install:
|
||||
# Dependencies
|
||||
deps-install: ## Install PHP dependencies with unlimited memory
|
||||
@echo "📦 Installing PHP dependencies..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer install
|
||||
yarn install
|
||||
@echo "✅ Dependencies installed"
|
||||
|
||||
# Complete test environment setup
|
||||
test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures
|
||||
test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures)
|
||||
@echo "✅ Test environment setup complete!"
|
||||
|
||||
# Clean test environment
|
||||
test-clean:
|
||||
test-clean: ## Clean test cache and database files
|
||||
@echo "🧹 Cleaning test environment..."
|
||||
rm -rf var/cache/test
|
||||
rm -f var/app_test.db
|
||||
@echo "✅ Test environment cleaned"
|
||||
|
||||
# Create test database
|
||||
test-db-create:
|
||||
test-db-create: ## Create test database (if not exists)
|
||||
@echo "🗄️ Creating test database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
# Run database migrations for test environment
|
||||
test-db-migrate:
|
||||
test-db-migrate: ## Run database migrations for test environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
|
||||
|
||||
# Clear test cache
|
||||
test-cache-clear:
|
||||
test-cache-clear: ## Clear test cache
|
||||
@echo "🗑️ Clearing test cache..."
|
||||
rm -rf var/cache/test
|
||||
@echo "✅ Test cache cleared"
|
||||
|
||||
# Load test fixtures
|
||||
test-fixtures:
|
||||
test-fixtures: ## Load test fixtures
|
||||
@echo "📦 Loading test fixtures..."
|
||||
php bin/console partdb:fixtures:load -n --env test
|
||||
|
||||
# Run PHPUnit tests
|
||||
test-run:
|
||||
test-run: ## Run PHPUnit tests
|
||||
@echo "🧪 Running tests..."
|
||||
php bin/phpunit
|
||||
|
||||
test-typecheck:
|
||||
@echo "🧪 Running type checks..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||
|
||||
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
||||
test-reset: test-cache-clear test-db-migrate test-fixtures
|
||||
@echo "✅ Test environment reset complete!"
|
||||
|
||||
test-typecheck: ## Run static analysis (PHPStan)
|
||||
@echo "🧪 Running type checks..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||
|
||||
# Development helpers
|
||||
dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
|
||||
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup)
|
||||
@echo "✅ Development environment setup complete!"
|
||||
|
||||
dev-clean:
|
||||
dev-clean: ## Clean development cache and database files
|
||||
@echo "🧹 Cleaning development environment..."
|
||||
rm -rf var/cache/dev
|
||||
rm -f var/app_dev.db
|
||||
@echo "✅ Development environment cleaned"
|
||||
|
||||
dev-db-create:
|
||||
dev-db-create: ## Create development database (if not exists)
|
||||
@echo "🗄️ Creating development database..."
|
||||
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
||||
|
||||
dev-db-migrate:
|
||||
dev-db-migrate: ## Run database migrations for development environment
|
||||
@echo "🔄 Running database migrations..."
|
||||
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
|
||||
|
||||
dev-cache-clear:
|
||||
dev-cache-clear: ## Clear development cache
|
||||
@echo "🗑️ Clearing development cache..."
|
||||
php -d memory_limit=1G bin/console cache:clear --env dev -n
|
||||
rm -rf var/cache/dev
|
||||
@echo "✅ Development cache cleared"
|
||||
|
||||
dev-warmup:
|
||||
dev-warmup: ## Warm up development cache
|
||||
@echo "🔥 Warming up development cache..."
|
||||
php -d memory_limit=1G bin/console cache:warmup --env dev -n
|
||||
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
|
||||
|
||||
dev-reset: dev-cache-clear dev-db-migrate
|
||||
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
|
||||
@echo "✅ Development environment reset complete!"
|
||||
70
migrations/Version20250802205143.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?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 Version20250802205143 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add bulk info provider import jobs and job parts tables';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
"bootbox": "^6.0.0",
|
||||
"bootswatch": "^5.1.3",
|
||||
"bs-custom-file-input": "^1.3.4",
|
||||
"ckeditor5": "^46.0.0",
|
||||
"ckeditor5": "^47.0.0",
|
||||
"clipboard": "^2.0.4",
|
||||
"compression-webpack-plugin": "^11.1.0",
|
||||
"datatables.net": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnDeprecation="false"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 482 B |
|
Before Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 352 B |
|
Before Width: | Height: | Size: 364 B |
|
Before Width: | Height: | Size: 489 B |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 346 B |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 779 B |
|
Before Width: | Height: | Size: 1,004 B |
|
Before Width: | Height: | Size: 645 B |
|
Before Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 362 B |
|
Before Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 134 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1,000 B |
|
Before Width: | Height: | Size: 96 B |
|
Before Width: | Height: | Size: 23 KiB |
|
|
@ -1,131 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
width="384"
|
||||
height="448"
|
||||
viewBox="0 0 384 448"
|
||||
id="svg7"
|
||||
sodipodi:docname="file_all.svg"
|
||||
inkscape:version="0.92.1 r15371">
|
||||
<metadata
|
||||
id="metadata13">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs11" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview9"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.52678571"
|
||||
inkscape:cx="192"
|
||||
inkscape:cy="192.54785"
|
||||
inkscape:window-x="1272"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg7" />
|
||||
<g
|
||||
id="icomoon-ignore" />
|
||||
<path
|
||||
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
|
||||
id="path5"
|
||||
style="fill:#1a1a1a" />
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot3687"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
|
||||
id="flowRegion3689"
|
||||
style="fill:#ffffff;"><rect
|
||||
id="rect3691"
|
||||
width="251.68207"
|
||||
height="110.74011"
|
||||
x="69.128677"
|
||||
y="214.43904"
|
||||
style="fill:#ffffff;" /></flowRegion><flowPara
|
||||
id="flowPara3693" /></flowRoot> <g
|
||||
aria-label="ALL "
|
||||
transform="matrix(1.7053159,0,0,1.4411413,-124.25849,-88.403923)"
|
||||
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#1a1a1a;fill-opacity:1;stroke:none"
|
||||
id="flowRoot3699">
|
||||
<path
|
||||
d="m 114.24512,247.89827 -6.32813,16.17188 h 6.9375 v 4.64062 H 98.260742 v -4.64062 h 4.031248 l 25.92188,-65.90625 h 5.57812 l 25.92188,65.90625 h 4.03125 v 4.64062 h -20.90625 v -4.64062 h 6.32812 l -6.375,-16.17188 z m 1.82812,-4.64062 h 24.89063 l -12.46875,-31.64063 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path40" />
|
||||
<path
|
||||
d="m 218.72949,268.71077 h -48.5625 v -4.64062 h 6.9375 v -60.14063 h -6.9375 v -4.59375 h 23.10938 v 4.59375 h -6.32813 v 59.57813 h 26.01563 v -8.67188 h 5.76562 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path42" />
|
||||
<path
|
||||
d="m 273.66699,268.71077 h -48.5625 v -4.64062 h 6.9375 v -60.14063 h -6.9375 v -4.59375 h 23.10938 v 4.59375 h -6.32813 v 59.57813 h 26.01563 v -8.67188 h 5.76562 z"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:96px;font-family:'Lucida Fax';-inkscape-font-specification:'Lucida Fax';text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path44" />
|
||||
</g>
|
||||
<g
|
||||
aria-label="DATASHEET"
|
||||
transform="matrix(1.3097344,0,0,1.4436797,-64.263952,-115.73324)"
|
||||
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#1a1a1a;fill-opacity:1;stroke:none"
|
||||
id="flowRoot3709">
|
||||
<path
|
||||
d="m 85.748047,302.43555 v 22.67578 h 4.765625 q 6.035156,0 8.828125,-2.73438 2.812503,-2.73437 2.812503,-8.63281 0,-5.85937 -2.812503,-8.57422 -2.792969,-2.73437 -8.828125,-2.73437 z m -3.945313,-3.24219 h 8.105469 q 8.476563,0 12.441407,3.53516 3.96484,3.51562 3.96484,11.01562 0,7.53906 -3.98437,11.07422 -3.984377,3.53516 -12.421877,3.53516 h -8.105469 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path21" />
|
||||
<path
|
||||
d="m 121.62695,303.08008 -5.35156,14.51172 h 10.72266 z m -2.22656,-3.88672 h 4.47266 l 11.11328,29.16016 h -4.10156 l -2.65625,-7.48047 h -13.14454 l -2.65625,7.48047 h -4.16015 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path23" />
|
||||
<path
|
||||
d="m 132.05664,299.19336 h 24.66797 v 3.32031 h -10.35156 v 25.83985 h -3.96485 v -25.83985 h -10.35156 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path25" />
|
||||
<path
|
||||
d="m 167.17383,303.08008 -5.35156,14.51172 h 10.72265 z m -2.22656,-3.88672 h 4.47265 l 11.11328,29.16016 h -4.10156 l -2.65625,-7.48047 h -13.14453 l -2.65625,7.48047 h -4.16016 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path27" />
|
||||
<path
|
||||
d="m 202.25195,300.15039 v 3.84766 q -2.24609,-1.07422 -4.23828,-1.60157 -1.99219,-0.52734 -3.84765,-0.52734 -3.22266,0 -4.98047,1.25 -1.73828,1.25 -1.73828,3.55469 0,1.93359 1.15234,2.92969 1.17187,0.97656 4.41406,1.58203 l 2.38281,0.48828 q 4.41407,0.83984 6.50391,2.96875 2.10938,2.10937 2.10938,5.66406 0,4.23828 -2.85157,6.42578 -2.83203,2.1875 -8.32031,2.1875 -2.07031,0 -4.41406,-0.46875 -2.32422,-0.46875 -4.82422,-1.38672 v -4.0625 q 2.40234,1.34766 4.70703,2.03125 2.30469,0.6836 4.53125,0.6836 3.37891,0 5.21484,-1.32813 1.83594,-1.32812 1.83594,-3.78906 0,-2.14844 -1.32812,-3.35938 -1.3086,-1.21093 -4.31641,-1.8164 l -2.40234,-0.46875 q -4.41407,-0.87891 -6.38672,-2.75391 -1.97266,-1.875 -1.97266,-5.21484 0,-3.86719 2.71485,-6.09375 2.73437,-2.22656 7.51953,-2.22656 2.05078,0 4.17968,0.37109 2.12891,0.37109 4.35547,1.11328 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path29" />
|
||||
<path
|
||||
d="m 210.16211,299.19336 h 3.94531 v 11.95312 h 14.33594 v -11.95312 h 3.94531 v 29.16016 h -3.94531 V 314.4668 h -14.33594 v 13.88672 h -3.94531 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path31" />
|
||||
<path
|
||||
d="m 240.24023,299.19336 h 18.4375 v 3.32031 h -14.49218 v 8.63281 h 13.88672 v 3.32032 h -13.88672 v 10.5664 h 14.84375 v 3.32032 h -18.78907 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path33" />
|
||||
<path
|
||||
d="m 265.55273,299.19336 h 18.4375 v 3.32031 h -14.49218 v 8.63281 h 13.88672 v 3.32032 h -13.88672 v 10.5664 h 14.84375 v 3.32032 h -18.78907 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path35" />
|
||||
<path
|
||||
d="m 286.82227,299.19336 h 24.66796 v 3.32031 h -10.35156 v 25.83985 h -3.96484 v -25.83985 h -10.35156 z"
|
||||
style="text-align:center;text-anchor:middle;fill:#1a1a1a"
|
||||
id="path37" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
|
@ -1,90 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
width="384"
|
||||
height="448"
|
||||
viewBox="0 0 384 448"
|
||||
id="svg7"
|
||||
sodipodi:docname="file_dc.svg"
|
||||
inkscape:version="0.92.1 r15371">
|
||||
<metadata
|
||||
id="metadata13">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs11" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview9"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.0535715"
|
||||
inkscape:cx="192"
|
||||
inkscape:cy="219.39394"
|
||||
inkscape:window-x="1272"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg7" />
|
||||
<g
|
||||
id="icomoon-ignore" />
|
||||
<path
|
||||
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
|
||||
id="path5"
|
||||
style="fill:#1a1a1a" />
|
||||
<rect
|
||||
id="rect3685"
|
||||
width="289.93774"
|
||||
height="149.66695"
|
||||
x="48.32296"
|
||||
y="188.2641"
|
||||
style="fill:#1a1a1a" />
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot3687"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
|
||||
id="flowRegion3689"
|
||||
style="fill:#ffffff;"><rect
|
||||
id="rect3691"
|
||||
width="251.68207"
|
||||
height="110.74011"
|
||||
x="69.128677"
|
||||
y="214.43904"
|
||||
style="fill:#ffffff;" /></flowRegion><flowPara
|
||||
id="flowPara3693" /></flowRoot> <text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:191.63136292px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.79078484"
|
||||
x="33.330128"
|
||||
y="354.68042"
|
||||
id="text3697"
|
||||
transform="scale(1.0793658,0.92646993)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan3695"
|
||||
x="33.330128"
|
||||
y="354.68042"
|
||||
style="fill:#ffffff;stroke-width:4.79078484">DC</tspan></text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3 KiB |
|
|
@ -1,5 +0,0 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
|
||||
<title>google</title>
|
||||
<path d="M12 12.281h11.328c0.109 0.609 0.187 1.203 0.187 2 0 6.844-4.594 11.719-11.516 11.719-6.641 0-12-5.359-12-12s5.359-12 12-12c3.234 0 5.953 1.188 8.047 3.141l-3.266 3.141c-0.891-0.859-2.453-1.859-4.781-1.859-4.094 0-7.438 3.391-7.438 7.578s3.344 7.578 7.438 7.578c4.75 0 6.531-3.406 6.813-5.172h-6.813v-4.125z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 485 B |
|
|
@ -1,5 +0,0 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 24 28">
|
||||
<title>cog</title>
|
||||
<path d="M16 14c0-2.203-1.797-4-4-4s-4 1.797-4 4 1.797 4 4 4 4-1.797 4-4zM24 12.297v3.469c0 0.234-0.187 0.516-0.438 0.562l-2.891 0.438c-0.172 0.5-0.359 0.969-0.609 1.422 0.531 0.766 1.094 1.453 1.672 2.156 0.094 0.109 0.156 0.25 0.156 0.391s-0.047 0.25-0.141 0.359c-0.375 0.5-2.484 2.797-3.016 2.797-0.141 0-0.281-0.063-0.406-0.141l-2.156-1.687c-0.453 0.234-0.938 0.438-1.422 0.594-0.109 0.953-0.203 1.969-0.453 2.906-0.063 0.25-0.281 0.438-0.562 0.438h-3.469c-0.281 0-0.531-0.203-0.562-0.469l-0.438-2.875c-0.484-0.156-0.953-0.344-1.406-0.578l-2.203 1.672c-0.109 0.094-0.25 0.141-0.391 0.141s-0.281-0.063-0.391-0.172c-0.828-0.75-1.922-1.719-2.578-2.625-0.078-0.109-0.109-0.234-0.109-0.359 0-0.141 0.047-0.25 0.125-0.359 0.531-0.719 1.109-1.406 1.641-2.141-0.266-0.5-0.484-1.016-0.641-1.547l-2.859-0.422c-0.266-0.047-0.453-0.297-0.453-0.562v-3.469c0-0.234 0.187-0.516 0.422-0.562l2.906-0.438c0.156-0.5 0.359-0.969 0.609-1.437-0.531-0.75-1.094-1.453-1.672-2.156-0.094-0.109-0.156-0.234-0.156-0.375s0.063-0.25 0.141-0.359c0.375-0.516 2.484-2.797 3.016-2.797 0.141 0 0.281 0.063 0.406 0.156l2.156 1.672c0.453-0.234 0.938-0.438 1.422-0.594 0.109-0.953 0.203-1.969 0.453-2.906 0.063-0.25 0.281-0.438 0.562-0.438h3.469c0.281 0 0.531 0.203 0.562 0.469l0.438 2.875c0.484 0.156 0.953 0.344 1.406 0.578l2.219-1.672c0.094-0.094 0.234-0.141 0.375-0.141s0.281 0.063 0.391 0.156c0.828 0.766 1.922 1.734 2.578 2.656 0.078 0.094 0.109 0.219 0.109 0.344 0 0.141-0.047 0.25-0.125 0.359-0.531 0.719-1.109 1.406-1.641 2.141 0.266 0.5 0.484 1.016 0.641 1.531l2.859 0.438c0.266 0.047 0.453 0.297 0.453 0.562z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,98 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
width="384"
|
||||
height="448"
|
||||
viewBox="0 0 384 448"
|
||||
id="svg7"
|
||||
sodipodi:docname="file_reichelt.svg"
|
||||
inkscape:version="0.92.1 r15371">
|
||||
<metadata
|
||||
id="metadata13">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs11" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview9"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.74498751"
|
||||
inkscape:cx="192"
|
||||
inkscape:cy="218.60367"
|
||||
inkscape:window-x="1272"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg7" />
|
||||
<g
|
||||
id="icomoon-ignore" />
|
||||
<path
|
||||
d="M367 95c9.25 9.25 17 27.75 17 41v288c0 13.25-10.75 24-24 24h-336c-13.25 0-24-10.75-24-24v-400c0-13.25 10.75-24 24-24h224c13.25 0 31.75 7.75 41 17zM256 34v94h94c-1.5-4.25-3.75-8.5-5.5-10.25l-78.25-78.25c-1.75-1.75-6-4-10.25-5.5zM352 416v-256h-104c-13.25 0-24-10.75-24-24v-104h-192v384h320z"
|
||||
id="path5"
|
||||
style="fill:#1a1a1a" />
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot3687"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;font-family:sans-serif;font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;letter-spacing:0px;word-spacing:0px;"><flowRegion
|
||||
id="flowRegion3689"
|
||||
style="fill:#ffffff;"><rect
|
||||
id="rect3691"
|
||||
width="251.68207"
|
||||
height="110.74011"
|
||||
x="69.128677"
|
||||
y="214.43904"
|
||||
style="fill:#ffffff;" /></flowRegion><flowPara
|
||||
id="flowPara3693" /></flowRoot> <rect
|
||||
style="fill:#666666;stroke-width:1.27060354"
|
||||
id="rect3719"
|
||||
width="150"
|
||||
height="150"
|
||||
x="98.65937"
|
||||
y="204.70981" />
|
||||
<rect
|
||||
style="fill:#1a1a1a;stroke-width:1.30443311"
|
||||
id="rect3717"
|
||||
width="150"
|
||||
height="150"
|
||||
x="130.20366"
|
||||
y="175.17915" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:1.07838833"
|
||||
id="rect3721"
|
||||
width="39.59798"
|
||||
height="138.9285"
|
||||
x="153.02271"
|
||||
y="198.33139" />
|
||||
<circle
|
||||
style="fill:#ffffff;stroke-width:1.69401228"
|
||||
id="path3723"
|
||||
cx="227.01927"
|
||||
cy="214.12033"
|
||||
r="30.69738" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 706 B |
|
Before Width: | Height: | Size: 606 B |
588
src/Controller/BulkInfoProviderImportController.php
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
<?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/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||
use App\Services\InfoProviderSystem\BulkInfoProviderService;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/tools/bulk_info_provider_import')]
|
||||
class BulkInfoProviderImportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BulkInfoProviderService $bulkService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
#[Autowire(param: 'partdb.bulk_import.batch_size')]
|
||||
private readonly int $bulkImportBatchSize,
|
||||
#[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')]
|
||||
private readonly int $bulkImportMaxParts
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert field mappings from array format to FieldMappingDTO[].
|
||||
*
|
||||
* @param array $fieldMappings Array of field mapping arrays
|
||||
* @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects
|
||||
*/
|
||||
private function convertFieldMappingsToDto(array $fieldMappings): array
|
||||
{
|
||||
$dtos = [];
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
|
||||
}
|
||||
return $dtos;
|
||||
}
|
||||
|
||||
private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse
|
||||
{
|
||||
$this->logger->warning('Bulk import operation failed', array_merge([
|
||||
'error' => $message,
|
||||
'user' => $this->getUser()?->getUserIdentifier(),
|
||||
], $context));
|
||||
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($job->getCreatedBy() !== $this->getUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void
|
||||
{
|
||||
if ($newResults === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only deserialize and update if we have new results
|
||||
$allResults = $job->getSearchResults($this->entityManager);
|
||||
|
||||
// Find and update the results for this specific part
|
||||
$allResults = $allResults->replaceResultsForPart($newResults);
|
||||
|
||||
// Save updated results back to job
|
||||
$job->setSearchResults($allResults);
|
||||
}
|
||||
|
||||
#[Route('/step1', name: 'bulk_info_provider_step1')]
|
||||
public function step1(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
set_time_limit(600);
|
||||
|
||||
$ids = $request->query->get('ids');
|
||||
if (!$ids) {
|
||||
$this->addFlash('error', 'No parts selected for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
$partIds = explode(',', $ids);
|
||||
$partRepository = $this->entityManager->getRepository(Part::class);
|
||||
$parts = $partRepository->getElementsFromIDArray($partIds);
|
||||
|
||||
if (empty($parts)) {
|
||||
$this->addFlash('error', 'No valid parts found for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
// Validate against configured maximum
|
||||
if (count($parts) > $this->bulkImportMaxParts) {
|
||||
$this->addFlash('error', sprintf(
|
||||
'Too many parts selected (%d). Maximum allowed is %d parts per operation.',
|
||||
count($parts),
|
||||
$this->bulkImportMaxParts
|
||||
));
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
if (count($parts) > ($this->bulkImportMaxParts / 2)) {
|
||||
$this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.');
|
||||
}
|
||||
|
||||
// Generate field choices
|
||||
$fieldChoices = [
|
||||
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||
'info_providers.bulk_search.field.name' => 'name',
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||
$fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
|
||||
}
|
||||
|
||||
// Initialize form with useful default mappings
|
||||
$initialData = [
|
||||
'field_mappings' => [
|
||||
['field' => 'mpn', 'providers' => [], 'priority' => 1]
|
||||
],
|
||||
'prefetch_details' => false
|
||||
];
|
||||
|
||||
$form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
|
||||
'field_choices' => $fieldChoices
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$searchResults = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$formData = $form->getData();
|
||||
$fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']);
|
||||
$prefetchDetails = $formData['prefetch_details'] ?? false;
|
||||
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('User must be authenticated and of type User');
|
||||
}
|
||||
|
||||
// Validate part count against configuration limit
|
||||
if (count($parts) > $this->bulkImportMaxParts) {
|
||||
$this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}");
|
||||
$partIds = array_map(fn($part) => $part->getId(), $parts);
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
|
||||
// Create and save the job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setFieldMappings($fieldMappingDtos);
|
||||
$job->setPrefetchDetails($prefetchDetails);
|
||||
$job->setCreatedBy($user);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$jobPart = new BulkInfoProviderImportJobPart($job, $part);
|
||||
$job->addJobPart($jobPart);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails);
|
||||
|
||||
// Save search results to job
|
||||
$job->setSearchResults($searchResultsDto);
|
||||
$job->markAsInProgress();
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails) {
|
||||
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Critical error during bulk import search', [
|
||||
'job_id' => $job->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage());
|
||||
$partIds = array_map(fn($part) => $part->getId(), $parts);
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing in-progress jobs for current user
|
||||
$existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step1.html.twig', [
|
||||
'form' => $form,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
'existing_jobs' => $existingJobs,
|
||||
'fieldChoices' => $fieldChoices
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/manage', name: 'bulk_info_provider_manage')]
|
||||
public function manageBulkJobs(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
// Get all jobs for current user
|
||||
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']);
|
||||
|
||||
// Check and auto-complete jobs that should be completed
|
||||
// Also clean up jobs with no results (failed searches)
|
||||
$updatedJobs = false;
|
||||
$jobsToDelete = [];
|
||||
|
||||
foreach ($allJobs as $job) {
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Mark jobs with no results for deletion (failed searches)
|
||||
if ($job->getResultCount() === 0 && $job->isInProgress()) {
|
||||
$jobsToDelete[] = $job;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete failed jobs
|
||||
foreach ($jobsToDelete as $job) {
|
||||
$this->entityManager->remove($job);
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Flush changes if any jobs were updated
|
||||
if ($updatedJobs) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (!empty($jobsToDelete)) {
|
||||
$this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/bulk_import/manage.html.twig', [
|
||||
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])]
|
||||
public function deleteJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Only allow deletion of completed, failed, or stopped jobs
|
||||
if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) {
|
||||
return $this->json(['error' => 'Cannot delete active job'], 400);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])]
|
||||
public function stopJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Only allow stopping of pending or in-progress jobs
|
||||
if (!$job->canBeStopped()) {
|
||||
return $this->json(['error' => 'Cannot stop job in current status'], 400);
|
||||
}
|
||||
|
||||
$job->markAsStopped();
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
|
||||
#[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')]
|
||||
public function step2(int $jobId): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job) {
|
||||
$this->addFlash('error', 'Bulk import job not found');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Check if user owns this job
|
||||
if ($job->getCreatedBy() !== $this->getUser()) {
|
||||
$this->addFlash('error', 'Access denied to this bulk import job');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Get the parts and deserialize search results
|
||||
$parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray();
|
||||
$searchResults = $job->getSearchResults($this->entityManager);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step2.html.twig', [
|
||||
'job' => $job,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])]
|
||||
public function markPartCompleted(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$job->markPartAsCompleted($partId);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])]
|
||||
public function markPartSkipped(int $jobId, int $partId, Request $request): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$reason = $request->request->get('reason', '');
|
||||
$job->markPartAsSkipped($partId, $reason);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])]
|
||||
public function markPartPending(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$job->markPartAsPending($partId);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])]
|
||||
public function researchPart(int $jobId, int $partId): JsonResponse
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$part = $this->entityManager->getRepository(Part::class)->find($partId);
|
||||
if (!$part) {
|
||||
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
|
||||
}
|
||||
|
||||
// Only refresh if the entity might be stale (optional optimization)
|
||||
if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) {
|
||||
$this->entityManager->refresh($part);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the job's field mappings to perform the search
|
||||
$fieldMappingDtos = $job->getFieldMappings();
|
||||
$prefetchDetails = $job->isPrefetchDetails();
|
||||
|
||||
try {
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
|
||||
} catch (\Exception $searchException) {
|
||||
// Handle "no search results found" as a normal case, not an error
|
||||
if (str_contains($searchException->getMessage(), 'No search results found')) {
|
||||
$searchResultsDto = null;
|
||||
} else {
|
||||
throw $searchException;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the job's search results for this specific part efficiently
|
||||
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails && $searchResultsDto !== null) {
|
||||
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Return the new results for this part
|
||||
$newResults = $searchResultsDto[0] ?? null;
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'part_id' => $partId,
|
||||
'results_count' => $newResults ? $newResults->getResultCount() : 0,
|
||||
'errors_count' => $newResults ? $newResults->getErrorCount() : 0,
|
||||
'message' => 'Part research completed successfully'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->createErrorResponse(
|
||||
'Research failed: ' . $e->getMessage(),
|
||||
500,
|
||||
[
|
||||
'job_id' => $jobId,
|
||||
'part_id' => $partId,
|
||||
'exception' => $e->getMessage()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
|
||||
public function researchAllParts(int $jobId): JsonResponse
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Get all parts that are not completed or skipped
|
||||
$parts = [];
|
||||
foreach ($job->getJobParts() as $jobPart) {
|
||||
if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) {
|
||||
$parts[] = $jobPart->getPart();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'message' => 'No parts to research',
|
||||
'researched_count' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$fieldMappingDtos = $job->getFieldMappings();
|
||||
$prefetchDetails = $job->isPrefetchDetails();
|
||||
|
||||
// Process in batches to reduce memory usage for large operations
|
||||
$allResults = new BulkSearchResponseDTO(partResults: []);
|
||||
$batches = array_chunk($parts, $this->bulkImportBatchSize);
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
$batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails);
|
||||
$allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto);
|
||||
|
||||
// Properly manage entity manager memory without losing state
|
||||
$jobId = $job->getId();
|
||||
//$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later
|
||||
$job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId);
|
||||
}
|
||||
|
||||
// Update the job's search results
|
||||
$job->setSearchResults($allResults);
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails) {
|
||||
$this->bulkService->prefetchDetailsForResults($allResults);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'researched_count' => count($parts),
|
||||
'message' => sprintf('Successfully researched %d parts', count($parts))
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->createErrorResponse(
|
||||
'Bulk research failed: ' . $e->getMessage(),
|
||||
500,
|
||||
[
|
||||
'job_id' => $jobId,
|
||||
'part_count' => count($parts),
|
||||
'exception' => $e->getMessage()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ namespace App\Controller;
|
|||
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\OAuthReconnectRequiredException;
|
||||
use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
|
|
@ -175,8 +176,11 @@ class InfoProviderController extends AbstractController
|
|||
$this->addFlash('error',$e->getMessage());
|
||||
//Log the exception
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (OAuthReconnectRequiredException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
|
||||
}
|
||||
|
||||
|
||||
// modify the array to an array of arrays that has a field for a matching local Part
|
||||
// the advantage to use that format even when we don't look for local parts is that we
|
||||
// always work with the same interface
|
||||
|
|
|
|||
|
|
@ -64,14 +64,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
use function Symfony\Component\Translation\t;
|
||||
|
||||
#[Route(path: '/part')]
|
||||
class PartController extends AbstractController
|
||||
final class PartController extends AbstractController
|
||||
{
|
||||
public function __construct(protected PricedetailHelper $pricedetailHelper,
|
||||
protected PartPreviewGenerator $partPreviewGenerator,
|
||||
public function __construct(
|
||||
private readonly PricedetailHelper $pricedetailHelper,
|
||||
private readonly PartPreviewGenerator $partPreviewGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
|
||||
protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
|
||||
{
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EventCommentHelper $commentHelper,
|
||||
private readonly PartInfoSettings $partInfoSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,9 +83,16 @@ class PartController extends AbstractController
|
|||
*/
|
||||
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
|
||||
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
|
||||
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
|
||||
{
|
||||
public function show(
|
||||
Part $part,
|
||||
Request $request,
|
||||
TimeTravel $timeTravel,
|
||||
HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable,
|
||||
ParameterExtractor $parameterExtractor,
|
||||
PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
?string $timestamp = null
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
|
||||
$timeTravel_timestamp = null;
|
||||
|
|
@ -132,7 +142,43 @@ class PartController extends AbstractController
|
|||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part);
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part, [], [
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])]
|
||||
public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
throw $this->createNotFoundException('Bulk import job not found');
|
||||
}
|
||||
|
||||
$bulkJob->markPartAsCompleted($part->getId());
|
||||
$this->em->persist($bulkJob);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Part marked as completed in bulk import');
|
||||
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
|
||||
|
|
@ -140,7 +186,7 @@ class PartController extends AbstractController
|
|||
{
|
||||
$this->denyAccessUnlessGranted('delete', $part);
|
||||
|
||||
if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) {
|
||||
if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) {
|
||||
|
||||
$this->commentHelper->setMessage($request->request->get('log_comment', null));
|
||||
|
||||
|
|
@ -159,11 +205,15 @@ class PartController extends AbstractController
|
|||
#[Route(path: '/new', name: 'part_new')]
|
||||
#[Route(path: '/{id}/clone', name: 'part_clone')]
|
||||
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
|
||||
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
public function new(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response
|
||||
{
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
|
||||
): Response {
|
||||
|
||||
if ($part instanceof Part) {
|
||||
//Clone part
|
||||
|
|
@ -258,9 +308,14 @@ class PartController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
|
||||
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
|
||||
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
|
||||
{
|
||||
public function updateFromInfoProvider(
|
||||
Part $part,
|
||||
Request $request,
|
||||
string $providerKey,
|
||||
string $providerId,
|
||||
PartInfoRetriever $infoRetriever,
|
||||
PartMerger $partMerger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
|
|
@ -274,10 +329,22 @@ class PartController extends AbstractController
|
|||
|
||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
||||
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('update_from_ip', $request, $part, [
|
||||
'info_provider_dto' => $dto,
|
||||
], [
|
||||
'tname_before' => $old_name
|
||||
'tname_before' => $old_name,
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +379,7 @@ class PartController extends AbstractController
|
|||
} catch (AttachmentDownloadException $attachmentDownloadException) {
|
||||
$this->addFlash(
|
||||
'error',
|
||||
$this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage()
|
||||
$this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -353,6 +420,12 @@ class PartController extends AbstractController
|
|||
return $this->redirectToRoute('part_new');
|
||||
}
|
||||
|
||||
// Check if we're in bulk import mode and preserve jobId
|
||||
$jobId = $request->query->get('jobId');
|
||||
if ($jobId && isset($merge_infos['bulk_job'])) {
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
|
||||
}
|
||||
|
||||
|
|
@ -371,13 +444,17 @@ class PartController extends AbstractController
|
|||
$template = 'parts/edit/update_from_ip.html.twig';
|
||||
}
|
||||
|
||||
return $this->render($template,
|
||||
return $this->render(
|
||||
$template,
|
||||
[
|
||||
'part' => $new_part,
|
||||
'form' => $form,
|
||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
||||
'merge_other' => $merge_infos['other_part'] ?? null
|
||||
]);
|
||||
'merge_other' => $merge_infos['other_part'] ?? null,
|
||||
'bulk_job' => $merge_infos['bulk_job'] ?? null,
|
||||
'jobId' => $request->query->get('jobId')
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -387,17 +464,17 @@ class PartController extends AbstractController
|
|||
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 instanceof PartLot) {
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if($partLot->getPart() !== $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), if the parameter is existing
|
||||
$targetId = $request->request->get('target_id', null);
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
if ($targetLot && $targetLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The target partlot does not belong to the part!");
|
||||
}
|
||||
|
|
@ -411,12 +488,12 @@ class PartController extends AbstractController
|
|||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if($timestamp_str !== '') {
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
|
|
@ -460,7 +537,7 @@ class PartController extends AbstractController
|
|||
|
||||
err:
|
||||
//If a redirect was passed, then redirect there
|
||||
if($request->request->get('_redirect')) {
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ use App\Exceptions\InvalidRegexException;
|
|||
use App\Form\Filters\PartFilterType;
|
||||
use App\Services\Parts\PartsTableActionHandler;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\BehaviorSettings\SidebarSettings;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\DBAL\Exception\DriverException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
|
@ -56,11 +57,21 @@ class PartListsController extends AbstractController
|
|||
private readonly NodesListBuilder $nodesListBuilder,
|
||||
private readonly DataTableFactory $dataTableFactory,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly TableSettings $tableSettings
|
||||
private readonly TableSettings $tableSettings,
|
||||
private readonly SidebarSettings $sidebarSettings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filter operator to use by default (INCLUDING_CHILDREN or =)
|
||||
* @return string
|
||||
*/
|
||||
private function getFilterOperator(): string
|
||||
{
|
||||
return $this->sidebarSettings->dataStructureNodesTableIncludeChildren ? 'INCLUDING_CHILDREN' : '=';
|
||||
}
|
||||
|
||||
#[Route(path: '/table/action', name: 'table_action', methods: ['POST'])]
|
||||
public function tableAction(Request $request, PartsTableActionHandler $actionHandler): Response
|
||||
{
|
||||
|
|
@ -154,12 +165,17 @@ class PartListsController extends AbstractController
|
|||
$filter_changer($filter);
|
||||
}
|
||||
|
||||
$filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
|
||||
if($form_changer !== null) {
|
||||
$form_changer($filterForm);
|
||||
}
|
||||
//If we are in a post request for the tables, we only have to apply the filter form if the submit query param was set
|
||||
//This saves us some time from creating this complicated term on simple list pages, where no special filter is applied
|
||||
$filterForm = null;
|
||||
if ($request->getMethod() !== 'POST' || $request->query->has('part_filter')) {
|
||||
$filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
|
||||
if ($form_changer !== null) {
|
||||
$form_changer($filterForm);
|
||||
}
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
$filterForm->handleRequest($formRequest);
|
||||
}
|
||||
|
||||
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
|
||||
['filter' => $filter], $additional_table_vars),
|
||||
|
|
@ -186,7 +202,7 @@ class PartListsController extends AbstractController
|
|||
|
||||
return $this->render($template, array_merge([
|
||||
'datatable' => $table,
|
||||
'filterForm' => $filterForm->createView(),
|
||||
'filterForm' => $filterForm?->createView(),
|
||||
], $additonal_template_vars));
|
||||
}
|
||||
|
||||
|
|
@ -198,7 +214,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/category_list.html.twig',
|
||||
function (PartFilter $filter) use ($category) {
|
||||
$filter->category->setOperator('INCLUDING_CHILDREN')->setValue($category);
|
||||
$filter->category->setOperator($this->getFilterOperator())->setValue($category);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('category')->get('value'));
|
||||
}, [
|
||||
|
|
@ -216,7 +232,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/footprint_list.html.twig',
|
||||
function (PartFilter $filter) use ($footprint) {
|
||||
$filter->footprint->setOperator('INCLUDING_CHILDREN')->setValue($footprint);
|
||||
$filter->footprint->setOperator($this->getFilterOperator())->setValue($footprint);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('footprint')->get('value'));
|
||||
}, [
|
||||
|
|
@ -234,7 +250,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/manufacturer_list.html.twig',
|
||||
function (PartFilter $filter) use ($manufacturer) {
|
||||
$filter->manufacturer->setOperator('INCLUDING_CHILDREN')->setValue($manufacturer);
|
||||
$filter->manufacturer->setOperator($this->getFilterOperator())->setValue($manufacturer);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('manufacturer')->get('value'));
|
||||
}, [
|
||||
|
|
@ -252,7 +268,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/store_location_list.html.twig',
|
||||
function (PartFilter $filter) use ($storelocation) {
|
||||
$filter->storelocation->setOperator('INCLUDING_CHILDREN')->setValue($storelocation);
|
||||
$filter->storelocation->setOperator($this->getFilterOperator())->setValue($storelocation);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value'));
|
||||
}, [
|
||||
|
|
@ -270,7 +286,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/supplier_list.html.twig',
|
||||
function (PartFilter $filter) use ($supplier) {
|
||||
$filter->supplier->setOperator('INCLUDING_CHILDREN')->setValue($supplier);
|
||||
$filter->supplier->setOperator($this->getFilterOperator())->setValue($supplier);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('supplier')->get('value'));
|
||||
}, [
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
|
|
@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
|
|||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Filter\AbstractFilter;
|
||||
|
||||
class AttachmentFilter implements FilterInterface
|
||||
{
|
||||
|
|
@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
|
|||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->dbId = new IntConstraint('attachment.id');
|
||||
$this->name = new TextConstraint('attachment.name');
|
||||
$this->targetType = new InstanceOfConstraint('attachment');
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
|
|||
{
|
||||
use FilterTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected string $identifier;
|
||||
protected ?string $identifier;
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ trait FilterTrait
|
|||
{
|
||||
|
||||
protected bool $useHaving = false;
|
||||
protected static int $parameterCounter = 0;
|
||||
|
||||
public function useHaving($value = true): static
|
||||
{
|
||||
|
|
@ -50,8 +51,18 @@ trait FilterTrait
|
|||
{
|
||||
//Replace all special characters with underscores
|
||||
$property = preg_replace('/\W/', '_', $property);
|
||||
//Add a random number to the end of the property name for uniqueness
|
||||
return $property . '_' . uniqid("", false);
|
||||
return $property . '_' . (self::$parameterCounter++) . '_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
|
||||
* This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
|
||||
* identifiers are deterministic so that they are cacheable.
|
||||
* @return void
|
||||
*/
|
||||
public static function resetParameterCounter(): void
|
||||
{
|
||||
self::$parameterCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobExistsConstraint extends BooleanConstraint
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_exists');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if value is null (filter is set to ignore)
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to avoid join conflicts
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
|
||||
->where('bip_exists.part = part.id');
|
||||
|
||||
if ($this->value === true) {
|
||||
// Filter for parts that ARE in bulk import jobs
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
} else {
|
||||
// Filter for parts that are NOT in bulk import jobs
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobStatusConstraint extends ChoiceConstraint
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_status');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has a job with the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_status')
|
||||
->join('bip_status.job', 'job_status')
|
||||
->where('bip_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->value);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportPartStatusConstraint extends ChoiceConstraint
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_part_status');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
|
||||
->where('bip_part_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->value);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
|
|||
//Escape any %, _ or \ in the tag
|
||||
$tag = addcslashes($tag, '%_\\');
|
||||
|
||||
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
|
||||
$tag_identifier_prefix = $this->generateParameterIdentifier('tag');
|
||||
|
||||
$expr = $queryBuilder->expr();
|
||||
|
||||
|
|
|
|||
|
|
@ -96,14 +96,15 @@ class TextConstraint extends AbstractConstraint
|
|||
|
||||
//The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently
|
||||
$like_value = null;
|
||||
$escaped_value = str_replace(['%', '_'], ['\%', '\_'], $this->value);
|
||||
if ($this->operator === 'LIKE') {
|
||||
$like_value = $this->value;
|
||||
$like_value = $this->value; //Here we do not escape anything, as the user may provide % and _ wildcards
|
||||
} elseif ($this->operator === 'STARTS') {
|
||||
$like_value = $this->value . '%';
|
||||
$like_value = $escaped_value . '%';
|
||||
} elseif ($this->operator === 'ENDS') {
|
||||
$like_value = '%' . $this->value;
|
||||
$like_value = '%' . $escaped_value;
|
||||
} elseif ($this->operator === 'CONTAINS') {
|
||||
$like_value = '%' . $this->value . '%';
|
||||
$like_value = '%' . $escaped_value . '%';
|
||||
}
|
||||
|
||||
if ($like_value !== null) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
|
|
@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->timestamp = new DateTimeConstraint('log.timestamp');
|
||||
$this->dbId = new IntConstraint('log.id');
|
||||
$this->level = new ChoiceConstraint('log.level');
|
||||
|
|
|
|||
|
|
@ -22,12 +22,16 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
use App\DataTables\Filters\Constraints\IntConstraint;
|
||||
use App\DataTables\Filters\Constraints\NumberConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
|
||||
|
|
@ -101,8 +105,19 @@ class PartFilter implements FilterInterface
|
|||
public readonly TextConstraint $bomName;
|
||||
public readonly TextConstraint $bomComment;
|
||||
|
||||
/*************************************************
|
||||
* Bulk Import Job tab
|
||||
*************************************************/
|
||||
|
||||
public readonly BulkImportJobExistsConstraint $inBulkImportJob;
|
||||
public readonly BulkImportJobStatusConstraint $bulkImportJobStatus;
|
||||
public readonly BulkImportPartStatusConstraint $bulkImportPartStatus;
|
||||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->name = new TextConstraint('part.name');
|
||||
$this->description = new TextConstraint('part.description');
|
||||
$this->comment = new TextConstraint('part.comment');
|
||||
|
|
@ -126,7 +141,7 @@ class PartFilter implements FilterInterface
|
|||
*/
|
||||
$this->amountSum = (new IntConstraint('(
|
||||
SELECT COALESCE(SUM(__partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' __partLot
|
||||
FROM ' . PartLot::class . ' __partLot
|
||||
WHERE __partLot.part = part.id
|
||||
AND __partLot.instock_unknown = false
|
||||
AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
|
||||
|
|
@ -162,6 +177,11 @@ class PartFilter implements FilterInterface
|
|||
$this->bomName = new TextConstraint('_projectBomEntries.name');
|
||||
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
|
||||
|
||||
// Bulk Import Job filters
|
||||
$this->inBulkImportJob = new BulkImportJobExistsConstraint();
|
||||
$this->bulkImportJobStatus = new BulkImportJobStatusConstraint();
|
||||
$this->bulkImportPartStatus = new BulkImportPartStatusConstraint();
|
||||
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ declare(strict_types=1);
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class PartSearchFilter implements FilterInterface
|
||||
|
|
@ -143,6 +144,8 @@ class PartSearchFilter implements FilterInterface
|
|||
if ($this->regex) {
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,23 +142,25 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
], alias: 'storage_location')
|
||||
|
||||
->add('amount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.amount'),
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'orderField' => 'amountSum'
|
||||
])
|
||||
->add('minamount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.minamount'),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
|
||||
$context->getPartUnit())),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
|
||||
$value,
|
||||
$context->getPartUnit()
|
||||
)),
|
||||
])
|
||||
->add('partUnit', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.partUnit'),
|
||||
'orderField' => 'NATSORT(_partUnit.name)',
|
||||
'render' => function($value, Part $context): string {
|
||||
'render' => function ($value, Part $context): string {
|
||||
$partUnit = $context->getPartUnit();
|
||||
if ($partUnit === null) {
|
||||
return '';
|
||||
|
|
@ -167,7 +169,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$tmp = htmlspecialchars($partUnit->getName());
|
||||
|
||||
if ($partUnit->getUnit()) {
|
||||
$tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
|
||||
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
|
|
@ -230,7 +232,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
}
|
||||
|
||||
if (count($projects) > $max) {
|
||||
$tmp .= ", + ".(count($projects) - $max);
|
||||
$tmp .= ", + " . (count($projects) - $max);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
|
|
@ -366,7 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$builder->addSelect(
|
||||
'(
|
||||
SELECT COALESCE(SUM(partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' partLot
|
||||
FROM ' . PartLot::class . ' partLot
|
||||
WHERE partLot.part = part.id
|
||||
AND partLot.instock_unknown = false
|
||||
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
|
||||
|
|
@ -423,6 +425,13 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_projectBomEntries');
|
||||
}
|
||||
if (str_contains($dql, '_jobPart')) {
|
||||
$builder->leftJoin('part.bulkImportJobParts', '_jobPart');
|
||||
$builder->leftJoin('_jobPart.job', '_bulkImportJob');
|
||||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_jobPart');
|
||||
//$builder->addGroupBy('_bulkImportJob');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ class ILike extends FunctionNode
|
|||
{
|
||||
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
|
||||
|
||||
//
|
||||
if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
|
||||
$operator = 'LIKE';
|
||||
} elseif ($platform instanceof PostgreSQLPlatform) {
|
||||
|
|
@ -66,6 +65,12 @@ class ILike extends FunctionNode
|
|||
throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
|
||||
}
|
||||
|
||||
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
|
||||
$escape = "";
|
||||
if ($platform instanceof SQLitePlatform) {
|
||||
//SQLite needs ESCAPE explicitly defined backslash as escape character
|
||||
$escape = " ESCAPE '\\'";
|
||||
}
|
||||
|
||||
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . $escape . ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
|||
/**
|
||||
* @var string The website of the company
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
#[Assert\Length(max: 255)]
|
||||
|
|
|
|||
35
src/Entity/InfoProviderSystem/BulkImportJobStatus.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\InfoProviderSystem;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum BulkImportJobStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case COMPLETED = 'completed';
|
||||
case STOPPED = 'stopped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
32
src/Entity/InfoProviderSystem/BulkImportPartStatus.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\InfoProviderSystem;
|
||||
|
||||
|
||||
enum BulkImportPartStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case COMPLETED = 'completed';
|
||||
case SKIPPED = 'skipped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
449
src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
<?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/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_jobs')]
|
||||
class BulkInfoProviderImportJob extends AbstractDBElement
|
||||
{
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $fieldMappings = [];
|
||||
|
||||
/**
|
||||
* @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance
|
||||
*/
|
||||
private ?array $fieldMappingsDTO = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $searchResults = [];
|
||||
|
||||
/**
|
||||
* @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance
|
||||
*/
|
||||
private ?BulkSearchResponseDTO $searchResultsDTO = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)]
|
||||
private BulkImportJobStatus $status = BulkImportJobStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $prefetchDetails = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
/** @var Collection<int, BulkInfoProviderImportJobPart> */
|
||||
#[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $jobParts;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->jobParts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getDisplayNameKey(): string
|
||||
{
|
||||
return 'info_providers.bulk_import.job_name_template';
|
||||
}
|
||||
|
||||
public function getDisplayNameParams(): array
|
||||
{
|
||||
return ['%count%' => $this->getPartCount()];
|
||||
}
|
||||
|
||||
public function getFormattedTimestamp(): string
|
||||
{
|
||||
return $this->createdAt->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobParts(): Collection
|
||||
{
|
||||
return $this->jobParts;
|
||||
}
|
||||
|
||||
public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->jobParts->contains($jobPart)) {
|
||||
$this->jobParts->add($jobPart);
|
||||
$jobPart->setJob($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->jobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getJob() === $this) {
|
||||
$jobPart->setJob(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPartIds(): array
|
||||
{
|
||||
return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray();
|
||||
}
|
||||
|
||||
public function setPartIds(array $partIds): self
|
||||
{
|
||||
// This method is kept for backward compatibility but should be replaced with addJobPart
|
||||
// Clear existing job parts
|
||||
$this->jobParts->clear();
|
||||
|
||||
// Add new job parts (this would need the actual Part entities, not just IDs)
|
||||
// This is a simplified implementation - in practice, you'd want to pass Part entities
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addPart(Part $part): self
|
||||
{
|
||||
$jobPart = new BulkInfoProviderImportJobPart($this, $part);
|
||||
$this->addJobPart($jobPart);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BulkSearchFieldMappingDTO[] The deserialized field mappings
|
||||
*/
|
||||
public function getFieldMappings(): array
|
||||
{
|
||||
if ($this->fieldMappingsDTO === null) {
|
||||
// Lazy load the DTOs from the raw JSON data
|
||||
$this->fieldMappingsDTO = array_map(
|
||||
static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data),
|
||||
$this->fieldMappings
|
||||
);
|
||||
}
|
||||
|
||||
return $this->fieldMappingsDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BulkSearchFieldMappingDTO[] $fieldMappings
|
||||
* @return $this
|
||||
*/
|
||||
public function setFieldMappings(array $fieldMappings): self
|
||||
{
|
||||
//Ensure that we are dealing with the objects here
|
||||
if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) {
|
||||
throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects');
|
||||
}
|
||||
|
||||
$this->fieldMappingsDTO = $fieldMappings;
|
||||
|
||||
$this->fieldMappings = array_map(
|
||||
static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(),
|
||||
$fieldMappings
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSearchResultsRaw(): array
|
||||
{
|
||||
return $this->searchResults;
|
||||
}
|
||||
|
||||
public function setSearchResultsRaw(array $searchResults): self
|
||||
{
|
||||
$this->searchResults = $searchResults;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSearchResults(BulkSearchResponseDTO $searchResponse): self
|
||||
{
|
||||
$this->searchResultsDTO = $searchResponse;
|
||||
$this->searchResults = $searchResponse->toSerializableRepresentation();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO
|
||||
{
|
||||
if ($this->searchResultsDTO === null) {
|
||||
// Lazy load the DTO from the raw JSON data
|
||||
$this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager);
|
||||
}
|
||||
return $this->searchResultsDTO;
|
||||
}
|
||||
|
||||
public function hasSearchResults(): bool
|
||||
{
|
||||
return !empty($this->searchResults);
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportJobStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportJobStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPrefetchDetails(): bool
|
||||
{
|
||||
return $this->prefetchDetails;
|
||||
}
|
||||
|
||||
public function setPrefetchDetails(bool $prefetchDetails): self
|
||||
{
|
||||
$this->prefetchDetails = $prefetchDetails;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(User $createdBy): self
|
||||
{
|
||||
$this->createdBy = $createdBy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProgress(): array
|
||||
{
|
||||
$progress = [];
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
$progressData = [
|
||||
'status' => $jobPart->getStatus()->value
|
||||
];
|
||||
|
||||
// Only include completed_at if it's not null
|
||||
if ($jobPart->getCompletedAt() !== null) {
|
||||
$progressData['completed_at'] = $jobPart->getCompletedAt()->format('c');
|
||||
}
|
||||
|
||||
// Only include reason if it's not null
|
||||
if ($jobPart->getReason() !== null) {
|
||||
$progressData['reason'] = $jobPart->getReason();
|
||||
}
|
||||
|
||||
$progress[$jobPart->getPart()->getId()] = $progressData;
|
||||
}
|
||||
return $progress;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::FAILED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsStopped(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::STOPPED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsInProgress(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::IN_PROGRESS;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::FAILED;
|
||||
}
|
||||
|
||||
public function isStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::STOPPED;
|
||||
}
|
||||
|
||||
public function canBeStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function getPartCount(): int
|
||||
{
|
||||
return $this->jobParts->count();
|
||||
}
|
||||
|
||||
public function getResultCount(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->searchResults as $partResult) {
|
||||
$count += count($partResult['search_results'] ?? []);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function markPartAsCompleted(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsCompleted();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsSkipped(int $partId, string $reason = ''): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsSkipped($reason);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsPending(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsPending();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPartCompleted(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isCompleted() : false;
|
||||
}
|
||||
|
||||
public function isPartSkipped(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isSkipped() : false;
|
||||
}
|
||||
|
||||
public function getCompletedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count();
|
||||
}
|
||||
|
||||
public function getSkippedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count();
|
||||
}
|
||||
|
||||
private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart
|
||||
{
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
if ($jobPart->getPart()->getId() === $partId) {
|
||||
return $jobPart;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getProgressPercentage(): float
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return round(($completed / $total) * 100, 1);
|
||||
}
|
||||
|
||||
public function isAllPartsCompleted(): bool
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return $completed >= $total;
|
||||
}
|
||||
}
|
||||
182
src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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);
|
||||
|
||||
/*
|
||||
* 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\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
|
||||
#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
|
||||
class BulkInfoProviderImportJobPart extends AbstractDBElement
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private BulkInfoProviderImportJob $job;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private Part $part;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)]
|
||||
private BulkImportPartStatus $status = BulkImportPartStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
public function __construct(BulkInfoProviderImportJob $job, Part $part)
|
||||
{
|
||||
$this->job = $job;
|
||||
$this->part = $part;
|
||||
}
|
||||
|
||||
public function getJob(): BulkInfoProviderImportJob
|
||||
{
|
||||
return $this->job;
|
||||
}
|
||||
|
||||
public function setJob(?BulkInfoProviderImportJob $job): self
|
||||
{
|
||||
$this->job = $job;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPart(): Part
|
||||
{
|
||||
return $this->part;
|
||||
}
|
||||
|
||||
public function setPart(?Part $part): self
|
||||
{
|
||||
$this->part = $part;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportPartStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportPartStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsSkipped(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::SKIPPED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::FAILED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsPending(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::PENDING;
|
||||
$this->reason = null;
|
||||
$this->completedAt = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isSkipped(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::SKIPPED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::FAILED;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
|
|||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
|
|
@ -67,6 +69,8 @@ enum LogTargetType: int
|
|||
case LABEL_PROFILE = 19;
|
||||
|
||||
case PART_ASSOCIATION = 20;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
|
||||
|
||||
/**
|
||||
* Returns the class name of the target type or null if the target type is NONE.
|
||||
|
|
@ -96,6 +100,8 @@ enum LogTargetType: int
|
|||
self::PARAMETER => AbstractParameter::class,
|
||||
self::LABEL_PROFILE => LabelProfile::class,
|
||||
self::PART_ASSOCIATION => PartAssociation::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity\Parts;
|
||||
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
|
|
@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
|
|||
use App\ApiPlatform\Filter\EntityFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\EDA\EDAPartInfo;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Parameters\ParametersTrait;
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
|
||||
|
|
@ -59,6 +59,7 @@ use App\Repository\PartRepository;
|
|||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
|
@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
|
||||
'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
||||
new Get(normalizationContext: [
|
||||
'groups' => [
|
||||
'part:read',
|
||||
'provider_reference:read',
|
||||
'api:basic:read',
|
||||
'part_lot:read',
|
||||
'orderdetail:read',
|
||||
'pricedetail:read',
|
||||
'parameter:read',
|
||||
'attachment:read',
|
||||
'eda_info:read'
|
||||
],
|
||||
'openapi_definition_name' => 'Read',
|
||||
], security: 'is_granted("read", object)'),
|
||||
new GetCollection(security: 'is_granted("@parts.read")'),
|
||||
|
|
@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
new Patch(security: 'is_granted("edit", object)'),
|
||||
new Delete(security: 'is_granted("delete", object)'),
|
||||
],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
|
|
@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
|
||||
#[ApiFilter(TagFilter::class, properties: ["tags"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
|
||||
|
|
@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
|
|||
#[Groups(['part:read'])]
|
||||
protected ?\DateTimeImmutable $lastModified = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
|
||||
protected Collection $bulkImportJobParts;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
|
@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
|
|||
|
||||
$this->associated_parts_as_owner = new ArrayCollection();
|
||||
$this->associated_parts_as_other = new ArrayCollection();
|
||||
$this->bulkImportJobParts = new ArrayCollection();
|
||||
|
||||
//By default, the part has no provider
|
||||
$this->providerReference = InfoProviderReference::noProvider();
|
||||
|
|
@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bulk import job parts for this part
|
||||
* @return Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
public function getBulkImportJobParts(): Collection
|
||||
{
|
||||
return $this->bulkImportJobParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a bulk import job part to this part
|
||||
*/
|
||||
public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->bulkImportJobParts->contains($jobPart)) {
|
||||
$this->bulkImportJobParts->add($jobPart);
|
||||
$jobPart->setPart($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bulk import job part from this part
|
||||
*/
|
||||
public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->bulkImportJobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getPart() === $this) {
|
||||
$jobPart->setPart(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ trait ManufacturerTrait
|
|||
/**
|
||||
* @var string The url to the part on the manufacturer's homepage
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
protected string $manufacturer_product_url = '';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ trait ProjectTrait
|
|||
/**
|
||||
* @var Collection<ProjectBOMEntry> $project_bom_entries
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'part', targetEntity: ProjectBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
|
||||
#[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'part')]
|
||||
protected Collection $project_bom_entries;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
|||
/**
|
||||
* @var string The URL to the product on the supplier's website
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
protected string $supplier_product_url = '';
|
||||
|
|
|
|||
59
src/EntityListeners/PartProjectBOMEntryUnlinkListener.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\EntityListeners;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||
|
||||
/**
|
||||
* If an part is deleted, this listener makes sure that all ProjectBOMEntries that reference this part, are updated
|
||||
* to not reference the part anymore, but instead store the part name in the name field.
|
||||
*/
|
||||
#[AsEntityListener(event: "preRemove", entity: Part::class)]
|
||||
class PartProjectBOMEntryUnlinkListener
|
||||
{
|
||||
public function preRemove(Part $part, PreRemoveEventArgs $event): void
|
||||
{
|
||||
// Iterate over all ProjectBOMEntries that use this part and put the part name into the name field
|
||||
foreach ($part->getProjectBomEntries() as $bom_entry) {
|
||||
$old_name = $bom_entry->getName();
|
||||
if ($old_name === null || trim($old_name) === '') {
|
||||
$bom_entry->setName($part->getName());
|
||||
} else {
|
||||
$bom_entry->setName($old_name . ' (' . $part->getName() . ')');
|
||||
}
|
||||
|
||||
$old_comment = $bom_entry->getComment();
|
||||
if ($old_comment === null || trim($old_comment) === '') {
|
||||
$bom_entry->setComment('Part was deleted: ' . $part->getName());
|
||||
} else {
|
||||
$bom_entry->setComment($old_comment . "\n\n Part was deleted: " . $part->getName());
|
||||
}
|
||||
|
||||
//Remove the part reference
|
||||
$bom_entry->setPart(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -170,6 +170,7 @@ class EventLoggerListener
|
|||
public function hasFieldRestrictions(AbstractDBElement $element): bool
|
||||
{
|
||||
foreach (array_keys(static::FIELD_BLACKLIST) as $class) {
|
||||
/** @var string $class */
|
||||
if ($element instanceof $class) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -184,6 +185,7 @@ class EventLoggerListener
|
|||
public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
|
||||
{
|
||||
foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
|
||||
/** @var string $class */
|
||||
if ($element instanceof $class && in_array($field_name, $blacklist, true)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -215,6 +217,7 @@ class EventLoggerListener
|
|||
$mappings = $metadata->getAssociationMappings();
|
||||
//Check if class is whitelisted for CollectionElementDeleted entry
|
||||
foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
|
||||
/** @var string $class */
|
||||
if ($entity instanceof $class) {
|
||||
//Check names
|
||||
foreach ($mappings as $field => $mapping) {
|
||||
|
|
|
|||
48
src/Exceptions/OAuthReconnectRequiredException.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class OAuthReconnectRequiredException extends \RuntimeException
|
||||
{
|
||||
private string $providerName = "unknown";
|
||||
|
||||
public function __construct(string $message = "You need to reconnect the OAuth connection for this provider!", int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function forProvider(string $providerName): self
|
||||
{
|
||||
$exception = new self("You need to reconnect the OAuth connection for the provider '$providerName'!");
|
||||
$exception->providerName = $providerName;
|
||||
return $exception;
|
||||
}
|
||||
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return $this->providerName;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,8 @@ class ImportType extends AbstractType
|
|||
'XML' => 'xml',
|
||||
'CSV' => 'csv',
|
||||
'YAML' => 'yaml',
|
||||
'XLSX' => 'xlsx',
|
||||
'XLS' => 'xls',
|
||||
],
|
||||
'label' => 'export.format',
|
||||
'disabled' => $disabled,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class LogFilterType extends AbstractType
|
|||
]);
|
||||
|
||||
$builder->add('user', UserEntityConstraintType::class, [
|
||||
'label' => 'log.user',
|
||||
'label' => 'log.user',
|
||||
]);
|
||||
|
||||
$builder->add('targetType', EnumConstraintType::class, [
|
||||
|
|
@ -128,11 +128,13 @@ class LogFilterType extends AbstractType
|
|||
LogTargetType::PARAMETER => 'parameter.label',
|
||||
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
||||
LogTargetType::PART_ASSOCIATION => 'part_association.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
|
||||
},
|
||||
]);
|
||||
|
||||
$builder->add('targetId', NumberConstraintType::class, [
|
||||
'label' => 'log.target_id',
|
||||
'label' => 'log.target_id',
|
||||
'min' => 1,
|
||||
'step' => 1,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Form\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||
use App\DataTables\Filters\PartFilter;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
|
||||
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
|
|
@ -33,8 +36,12 @@ use App\Entity\Parts\StorageLocation;
|
|||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Form\Filters\Constraints\BooleanConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\ChoiceConstraintType;
|
||||
use App\Form\Filters\Constraints\DateTimeConstraintType;
|
||||
use App\Form\Filters\Constraints\EnumConstraintType;
|
||||
use App\Form\Filters\Constraints\NumberConstraintType;
|
||||
use App\Form\Filters\Constraints\ParameterConstraintType;
|
||||
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
|
||||
|
|
@ -50,6 +57,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
|||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class PartFilterType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly Security $security)
|
||||
|
|
@ -298,6 +307,31 @@ class PartFilterType extends AbstractType
|
|||
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* Bulk Import Job tab
|
||||
**************************************************************************/
|
||||
if ($this->security->isGranted('@info_providers.create_parts')) {
|
||||
$builder
|
||||
->add('inBulkImportJob', BooleanConstraintType::class, [
|
||||
'label' => 'part.filter.in_bulk_import_job',
|
||||
])
|
||||
->add('bulkImportJobStatus', EnumConstraintType::class, [
|
||||
'enum_class' => BulkImportJobStatus::class,
|
||||
'label' => 'part.filter.bulk_import_job_status',
|
||||
'choice_label' => function (BulkImportJobStatus $value) {
|
||||
return t('bulk_import.status.' . $value->value);
|
||||
},
|
||||
])
|
||||
->add('bulkImportPartStatus', EnumConstraintType::class, [
|
||||
'enum_class' => BulkImportPartStatus::class,
|
||||
'label' => 'part.filter.bulk_import_part_status',
|
||||
'choice_label' => function (BulkImportPartStatus $value) {
|
||||
return t('bulk_import.part_status.' . $value->value);
|
||||
},
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'filter.submit',
|
||||
|
|
|
|||