Merge branch 'api'

This commit is contained in:
Jan Böhmer 2023-10-06 12:29:53 +02:00
commit 8c9abce633
169 changed files with 8149 additions and 1887 deletions

4
.env
View file

@ -209,3 +209,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

View file

@ -48,9 +48,10 @@ jobs:
- name: Check doctrine mapping
run: ./bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction
# Use the -d option to raise the max nesting level
- name: Generate dev container
run: ./bin/console cache:clear --env dev
run: php -d xdebug.max_nesting_level=1000 ./bin/console cache:clear --env dev
- name: Run PHPstan
run: composer phpstan

View file

@ -20,6 +20,7 @@
'use strict';
import {Dropdown} from "bootstrap";
import ClipboardJS from "clipboard";
class RegisterEventHelper {
constructor() {
@ -27,6 +28,11 @@ class RegisterEventHelper {
this.configureDropdowns();
this.registerSpecialCharInput();
//Initialize ClipboardJS
this.registerLoadHandler(() => {
new ClipboardJS('.btn');
})
this.registerModalDropRemovalOnFormSubmit();
}

View file

@ -10,6 +10,7 @@
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"api-platform/core": "^3.1",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.11.0",
"composer/package-versions-deprecated": "^1.11.99.5",
@ -33,6 +34,7 @@
"liip/imagine-bundle": "^2.2",
"nbgrp/onelogin-saml-bundle": "^1.3",
"nelexa/zip": "^4.0",
"nelmio/cors-bundle": "^2.3",
"nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1",
"ocramius/proxy-manager": "2.2.*",
@ -40,6 +42,7 @@
"part-db/label-fonts": "^1.0",
"php-translation/symfony-bundle": "^0.14.0",
"phpdocumentor/reflection-docblock": "^5.2",
"phpstan/phpdoc-parser": "^1.23",
"s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^6.8.0",
"scheb/2fa-bundle": "^6.8.0",
@ -94,6 +97,7 @@
"phpstan/phpstan-doctrine": "^1.2.11",
"phpstan/phpstan-strict-rules": "^1.5",
"phpstan/phpstan-symfony": "^1.1.7",
"phpunit/phpunit": "^9.5",
"psalm/plugin-symfony": "^v5.0.1",
"rector/rector": "^0.18.0",
"roave/security-advisories": "dev-latest",

1914
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -31,4 +31,6 @@ return [
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
Jbtronics\DompdfFontLoaderBundle\DompdfFontLoaderBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
];

View file

@ -0,0 +1,19 @@
api_platform:
title: 'Part-DB API'
description: 'API of Part-DB'
version: '0.1.0'
# eager_loading:
# max_joins: 100
swagger:
api_keys:
# overridden in OpenApiFactoryDecorator
access_token:
name: Authorization
type: header
defaults:
pagination_client_items_per_page: true # Allow clients to override the default items per page

View file

@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View file

@ -24,6 +24,9 @@ security:
# Enable user impersonation
switch_user: { role: CAN_SWITCH_USER }
custom_authenticators:
- App\Security\ApiTokenAuthenticator
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
@ -66,3 +69,5 @@ security:
# We get into trouble with the U2F authentication, if the calls to the trees trigger an 2FA login
# This settings should not do much harm, because a read only access to show available data structures is not really critical
- { path: "^/\\w{2}/tree", role: PUBLIC_ACCESS }
# Restrict access to API to users, which has the API access permission
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }

View file

@ -25,27 +25,35 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
# If a part can be read by a user, he can also see all the datastructures (except devices)
alsoSet: ['storelocations.read', 'footprints.read', 'categories.read', 'suppliers.read', 'manufacturers.read',
'currencies.read', 'attachment_types.read', 'measurement_units.read']
apiTokenRole: ROLE_API_READ_ONLY
edit:
label: "perm.edit"
alsoSet: ['read', 'parts_stock.withdraw', 'parts_stock.add', 'parts_stock.move']
apiTokenRole: ROLE_API_EDIT
create:
label: "perm.create"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_EDIT
delete:
label: "perm.delete"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_EDIT
change_favorite:
label: "perm.part.change_favorite"
alsoSet: ['edit']
apiTokenRole: ROLE_API_EDIT
show_history:
label: "perm.part.show_history"
alsoSet: ['read']
apiTokenRole: ROLE_API_READ_ONLY
revert_element:
label: "perm.revert_elements"
alsoSet: ["read", "edit", "create", "delete", "show_history"]
apiTokenRole: ROLE_API_EDIT
import:
label: "perm.import"
alsoSet: ["read", "edit", "create"]
apiTokenRole: ROLE_API_EDIT
parts_stock:
group: "data"
@ -53,10 +61,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
operations:
withdraw:
label: "perm.parts_stock.withdraw"
apiTokenRole: ROLE_API_EDIT
add:
label: "perm.parts_stock.add"
apiTokenRole: ROLE_API_EDIT
move:
label: "perm.parts_stock.move"
apiTokenRole: ROLE_API_EDIT
storelocations: &PART_CONTAINING
@ -65,23 +76,30 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
operations:
read:
label: "perm.read"
apiTokenRole: ROLE_API_READ_ONLY
edit:
label: "perm.edit"
alsoSet: 'read'
apiTokenRole: ROLE_API_EDIT
create:
label: "perm.create"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_EDIT
delete:
label: "perm.delete"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_EDIT
show_history:
label: "perm.show_history"
apiTokenRole: ROLE_API_READ_ONLY
revert_element:
label: "perm.revert_elements"
alsoSet: ["read", "edit", "create", "delete", "show_history"]
apiTokenRole: ROLE_API_EDIT
import:
label: "perm.import"
alsoSet: [ "read", "edit", "create" ]
apiTokenRole: ROLE_API_EDIT
footprints:
<<: *PART_CONTAINING
@ -145,6 +163,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
create_parts:
label: "perm.part.info_providers.create_parts"
alsoSet: ['parts.create']
apiTokenRole: ROLE_API_EDIT
groups:
label: "perm.groups"
@ -152,26 +171,34 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
operations:
read:
label: "perm.read"
apiTokenRole: ROLE_API_ADMIN
edit:
label: "perm.edit"
alsoSet: 'read'
apiTokenRole: ROLE_API_ADMIN
create:
label: "perm.create"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_ADMIN
delete:
label: "perm.delete"
alsoSet: ['read', 'delete']
apiTokenRole: ROLE_API_ADMIN
edit_permissions:
label: "perm.edit_permissions"
alsoSet: ['read', 'edit']
apiTokenRole: ROLE_API_ADMIN
show_history:
label: "perm.show_history"
apiTokenRole: ROLE_API_ADMIN
revert_element:
label: "perm.revert_elements"
alsoSet: ["read", "edit", "create", "delete", "edit_permissions", "show_history"]
apiTokenRole: ROLE_API_ADMIN
import:
label: "perm.import"
alsoSet: [ "read", "edit", "create" ]
apiTokenRole: ROLE_API_ADMIN
users:
label: "perm.users"
@ -179,37 +206,49 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
operations:
read:
label: "perm.read"
apiTokenRole: ROLE_API_ADMIN
create:
label: "perm.create"
alsoSet: ['read', 'edit_username', 'edit_infos']
apiTokenRole: ROLE_API_ADMIN
delete:
label: "perm.delete"
alsoSet: ['read', 'edit_username', 'edit_infos']
apiTokenRole: ROLE_API_ADMIN
edit_username:
label: "perm.users.edit_user_name"
alsoSet: ['read']
apiTokenRole: ROLE_API_ADMIN
edit_infos:
label: "perm.users.edit_infos"
alsoSet: 'read'
apiTokenRole: ROLE_API_ADMIN
edit_permissions:
label: "perm.users.edit_permissions"
alsoSet: 'read'
apiTokenRole: ROLE_API_ADMIN
set_password:
label: "perm.users.set_password"
alsoSet: 'read'
apiTokenRole: ROLE_API_FULL
impersonate:
label: "perm.users.impersonate"
alsoSet: ['set_password']
apiTokenRole: ROLE_API_FULL
change_user_settings:
label: "perm.users.change_user_settings"
apiTokenRole: ROLE_API_ADMIN
show_history:
label: "perm.show_history"
apiTokenRole: ROLE_API_ADMIN
revert_element:
label: "perm.revert_elements"
alsoSet: ["read", "create", "delete", "edit_permissions", "show_history", "edit_infos", "edit_username"]
apiTokenRole: ROLE_API_ADMIN
import:
label: "perm.import"
alsoSet: [ "read", "create" ]
apiTokenRole: ROLE_API_ADMIN
#database:
# label: "perm.database"
@ -244,64 +283,94 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
operations:
show_logs:
label: "perm.show_logs"
apiTokenRole: ROLE_API_ADMIN
delete_logs:
label: "perm.delete_logs"
alsoSet: 'show_logs'
apiTokenRole: ROLE_API_ADMIN
server_infos:
label: "perm.server_infos"
apiTokenRole: ROLE_API_ADMIN
manage_oauth_tokens:
label: "Manage OAuth tokens"
apiTokenRole: ROLE_API_ADMIN
show_updates:
label: "perm.system.show_available_updates"
apiTokenRole: ROLE_API_ADMIN
attachments:
label: "perm.part.attachments"
operations:
show_private:
label: "perm.attachments.show_private"
apiTokenRole: ROLE_API_READ_ONLY
list_attachments:
label: "perm.attachments.list_attachments"
alsoSet: ['attachment_types.read']
apiTokenRole: ROLE_API_READ_ONLY
self:
label: "perm.self"
operations:
edit_infos:
label: "perm.self.edit_infos"
apiTokenRole: ROLE_API_FULL
edit_username:
label: "perm.self.edit_username"
apiTokenRole: ROLE_API_FULL
show_permissions:
label: "perm.self.show_permissions"
apiTokenRole: ROLE_API_READ_ONLY
show_logs:
label: "perm.self.show_logs"
apiTokenRole: ROLE_API_FULL
labels:
label: "perm.labels"
operations:
create_labels:
label: "perm.self.create_labels"
apiTokenRole: ROLE_API_READ_ONLY
edit_options:
label: "perm.self.edit_options"
alsoSet: ['create_labels']
apiTokenRole: ROLE_API_READ_ONLY
read_profiles:
label: "perm.self.read_profiles"
apiTokenRole: ROLE_API_READ_ONLY
edit_profiles:
label: "perm.self.edit_profiles"
alsoSet: ['read_profiles']
apiTokenRole: ROLE_API_EDIT
create_profiles:
label: "perm.self.create_profiles"
alsoSet: ['read_profiles', 'edit_profiles']
apiTokenRole: ROLE_API_EDIT
delete_profiles:
label: "perm.self.delete_profiles"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles']
apiTokenRole: ROLE_API_EDIT
use_twig:
label: "perm.labels.use_twig"
alsoSet: ['create_labels', 'edit_options']
apiTokenRole: ROLE_API_ADMIN
show_history:
label: "perm.show_history"
alsoSet: ['read_profiles']
apiTokenRole: ROLE_API_READ_ONLY
revert_element:
label: "perm.revert_elements"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
apiTokenRole: ROLE_API_EDIT
api:
label: "perm.api"
operations:
access_api:
label: "perm.api.access_api"
apiTokenRole: ROLE_API_READ_ONLY
manage_tokens:
label: "perm.api.manage_tokens"
alsoSet: ['access_api']
apiTokenRole: ROLE_API_FULL

View file

@ -15,5 +15,5 @@ redirector:
requirements:
url: ".*"
controller: App\Controller\RedirectController::addLocalePart
# Dont match localized routes (no redirection loop, if no root with that name exists)
condition: "not (request.getPathInfo() matches '/^\\\\/[a-z]{2}(_[A-Z]{2})?\\\\//')"
# Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"

View file

@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View file

@ -277,6 +277,15 @@ services:
$search_limit: '%env(int:PROVIDER_OCTOPART_SEARCH_LIMIT)%'
$onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%'
####################################################################################################################
# API system
####################################################################################################################
App\State\PartDBInfoProvider:
arguments:
$default_uri: '%partdb.default_uri%'
$global_locale: '%partdb.locale%'
$global_timezone: '%partdb.timezone%'
####################################################################################################################
# Symfony overrides
####################################################################################################################
@ -319,6 +328,12 @@ services:
arguments:
$check_for_updates: '%partdb.check_for_updates%'
App\Services\System\BannerHelper:
arguments:
$partdb_banner: '%partdb.banner%'
$project_dir: '%kernel.project_dir%'
####################################################################################################################
# Monolog
####################################################################################################################

View file

@ -0,0 +1,61 @@
---
title: Authentication
layout: default
parent: API
nav_order: 2
---
# Authentication
To use API endpoints, the external application has to authenticate itself, so that Part-DB knows which user is accessing the data and which permissions
the application should have during the access. Authentication is always bound to a specific user, so the external applications is acting on behalf of a
specific user. This user limits the permissions of the application, so that it can only access data, which the user is allowed to access.
The only method currently available for authentication is to use API tokens:
## API tokens
An API token is a long alphanumeric string, which is bound to a specific user and can be used to authenticate as this user, when accessing the API.
The API token is passed via the `Authentication` HTTP header during the API request, like the following: `Authentication: Bearer tcp_sdjfks....`.
{: .important }
> Everbody who knows the API token can access the API as the user, which is bound to the token. So you should treat the API token like a password
> and keep it secret. Only share it with trusted applications.
API tokens can be created and managed on the user settings page in the API token section. You can create as many API tokens as you want and also delete them again.
When deleting a token, it is immediately invalidated and can not be used anymore, which means that the application can not access the API anymore with this token.
### Token permissions and scopes
API tokens are ultimately limited by the permissions of the user, which belongs to the token. That means that the token
can only access data, which the user is allowed to access, no matter the token permissions.
But you can further limit the permissions of a token by choosing a specific scope for the token. The scope defines which
subset of permissions the token has, which can be less than the permissions of the user. For example you can have a user
with full read and write permissions, but create a token with only read permissions, which can only read data, but not
change anything in the database.
{: .warning }
> In general you should always use the least possible permissions for a token, to limit the possible damage, which can be done with a stolen token or a bug in the application.
> Only use the full or admin scope, if you really need it, as they could potentially be used to do a lot of damage to your Part-DB instance.
Following token scopes are available:
* **Read-Only**: The token can only read non-sensitive data (like parts, but no users or groups) from the API and can not change anything.
* **Edit**: The token can read and write non-sensitive data via the API. This includes creating, updating and deleting data. This should be enough for most applications.
* **Admin**: The token can read and write all data via the API, including sensitive data like users and groups. This should only be used for trusted applications, which need to access sensitive data, and perform administrative actions.
* **Full**: The token can do anything the user can do, including changing the users password and create new tokens. This should only be used for highly trusted applications!!
Please note, that in early versions of the API, there might be no endpoints yet, to really perform the actions, which would be allowed by the token scope.
### Expiration date
API tokens can have an expiration date, which means that the token is only valid until the expiration date. After that
the token is automatically invalidated and can not be used anymore. The token is still listed on the user settings page,
and can be deleted there, but the code can not be used to access Part-DB anymore after the expiration date.
### Get token information
When authenticating with an API token, you can get information about the currently used token by accessing the `/api/tokens/current` endpoint.
It gives you information about the token scope, expiration date and the user, which is bound to the token and the last time the token was used.

11
docs/api/index.md Normal file
View file

@ -0,0 +1,11 @@
---
layout: default
title: API
nav_order: 7
has_children: true
---
# API
Part-DB provides a REST API to access the data stored in the database.
In this section you can find information about the API and how to use it.

132
docs/api/intro.md Normal file
View file

@ -0,0 +1,132 @@
---
title: Introduction
layout: default
parent: API
nav_order: 1
---
# Introduction
Part-DB provides a [REST API](https://en.wikipedia.org/wiki/REST) to programmatically access the data stored in the database.
This allows external applications to interact with Part-DB, extend it or integrate it into other applications.
{: .warning }
> This feature is currently in beta. Please report any bugs you find.
> The API should not be considered stable yet and could change in future versions, without prior notice.
> Some features might be missing or not working yet.
> Also be aware, that there might be security issues in the API, which could allow attackers to access or edit data via the API, which
> they normally should be able to access. So currently you should only use the API with trusted users and trusted applications.
Part-DB uses [API Platform](https://api-platform.com/) to provide the API, which allows for easy creation of REST APIs with Symfony and gives you a lot of features out of the box.
See the [API Platform documentation](https://api-platform.com/docs/core/) for more details about the API Platform features and how to use them.
## Enable the API
The API is available under the `/api` path, but not reachable without proper permissions.
You have to give the users, which should be able to access the API the proper permissions (Misceallaneous -> API).
Please note that there are two relevant permissions, the first one allows users to access the `/api/` path at all and showing the documentation,
and the second one allows them to create API tokens which is needed for authentication of external applications.
## Authentication
To use API endpoints, the external application has to authenticate itself, so that Part-DB knows which user is accessing the data and
which permisions the application should have. Basically this is done by creating a API token for a user and then passing it on every request
with the `Authorization` header as bearer token, so you add a header `Authorization: Bearer <your token>`.
See [Authentication chapter]({% link api/authentication.md %}) for more details.
## API endpoints
The API is split into different endpoints, which are reachable under the `/api/` path of your Part-DB instance (so `https://your-part-db.local/api/`).
There are various endpoints for each entity type (like `part`, `manufacturer`, etc.), which allow you to read and write data and some special endpoints like `search` or `statistics`.
For example all API endpoints for managing categories are available under `/api/categories/`. Depending on the exact path and the HTTP method used, you can read, create, update or delete categories.
For most entities, there are endpoints like this:
* **GET**: `/api/categories/` - List all categories in the database (with pagination of the results)
* **POST**: `/api/categories/` - Create a new category
* **GET**: `/api/categories/{id}` - Get a specific category by its ID
* **DELETE**: `/api/categories/{id}` - Delete a specific category by its ID
* **UPDATE**: `/api/categories/{id}` - Update a specific category by its ID. Only the fields which are sent in the request are updated, all other fields are left unchanged. Be aware that you have to set the [JSON Patch](https://en.wikipedia.org/wiki/JSON_Patch) content type header (`Content-Type: application/merge-patch+json`) for this to work.
A full (interactive) list of endpoints can be displayed when visiting the `/api/` path in your browser, when you are logged in with a user, which is allowed to access the API.
There is also a link to this page, on the user settings page in the API token section.
This documentation also list all available fields for each entity type and the allowed operations.
## Formats
The API supports different formats for the request and response data, which you can control via the `Accept` and `Content-Type` headers.
You should use [JSON-LD](https://json-ld.org/) as format, which is basically JSON with some additional metadata, which allows
to describe the data in a more structured way and also allows to link between different entities. You can achieve this by setting `Accept: application/ld+json` header to the API requests.
To get plain JSON without any metadata or links, use the `Accept: application/json` header.
## OpenAPI schema
Part-DB provides a [OpenAPI](https://swagger.io/specification/) (formally Swagger) schema for the API under `/api/docs.json` (so `https://your-part-db.local/api/docs.json`).
This schema is a machine readable description of the API, which can be imported in software to test the API or even automatically generate client libraries for the API.
API generators which can generate a client library for the API from the schema are available for many programming languages, like [OpenAPI Generator](https://openapi-generator.tech/).
An JSONLD/Hydra version of the schema is also available under `/api/docs.jsonld` (so `https://your-part-db.local/api/docs.jsonld`).
## Interactive documentation
Part-DB provides an interactive documentation for the API, which is available under `/api/docs` (so `https://your-part-db.local/api/docs`).
You can pass your API token in the form on the top of the page, to authenticate yourself and then you can try out the API directly in the browser.
This is a great way to test the API and see how it works, without having to write any code.
## Pagination
By default all list endpoints are paginated, which means only a certain number of results is returned per request.
To get another page of the results, you have to use the `page` query parameter, which contains the page number you want to get (e.g. `/api/categoues/?page=2`).
When using JSONLD, the links to the next page are also included in the `hydra:view` property of the response.
To change the size of the pages (the number of items in a single page) use the `itemsPerPage` query parameter (e.g. `/api/categoues/?itemsPerPage=50`).
See [API Platform docs](https://api-platform.com/docs/core/pagination) for more infos.
## Filtering results / Searching
When retrieving a list of entities, you can restrict the results by various filters. Almost all entities have a search filter,
which allows you to only include entities, which (text) fields match the given search term: For example if you only want to
get parts, with the Name "BC547", you can use `/api/parts.jsonld?name=BC547`. You can use `%` as wildcard for multiple characters
in the search term (Be sure to properly encode the search term, if you use special characters). For example if you want to get all parts,
whose name starts with "BC", you can use `/api/parts.jsonld?name=BC%25` (the `%25` is the url encoded version of `%`).
There are other filters available for some entities, allowing you to search on other fields, or restricting the results
by numeric values or dates. See the endpoint documentation for the available filters.
## Filter by associated entities
To get all parts with a certain category, manufacturer, etc. you can use the `category`, `manufacturer`, etc. query parameters of the `/api/parts` endpoint.
They are so called entitiy filters and accept a comma separated list of IDs of the entities you want to filter by.
For example if you want to get all parts with the category "Resistor" (Category ID 1) and "Capacitor" (Category ID 2), you can use `/api/parts.jsonld?category=1,2`.
Suffix an id with `+` to suffix, to include all direct children categories of the given category. Use the `++` suffix to include all children categories recursively.
To get all parts with the category "Resistor" (Category ID 1) and all children categories of "Capacitor" (Category ID 2), you can use `/api/parts.jsonld?category=1,2++`.
See the endpoint documentation for the available entity filters.
## Ordering results
When retrieving a list of entities, you can order the results by various fields using the `order` query parameter.
For example if you want to get all parts ordered by their name, you can use `/api/parts/?order[name]=asc`. You can use
this parameter multiple times to order by multiple fields.
See the endpoint documentation for the available fields to order by.
## Property filter
Sometimes you only want to get a subset of the properties of an entity, for example when you only need the name of a part, but not all the other properties.
You can achieve this using the `properties[]` query parameter with the name of the field you want to get. You can use this parameter multiple times to get multiple fields.
For example if you only want to get the name and the description of a part, you can use `/api/parts/123?properties[]=name&properties[]=description`.
It is also possible to use this filters on list endpoints (get collection), to only get a subset of the properties of all entities in the collection.
See [API Platform docs](https://api-platform.com/docs/core/filters/#property-filter) for more infos.
## Change comment
Similar to the changes using Part-DB web interface, you can add a change comment to every change you make via the API, which will be
visible in the log of the entity.
You can pass the text for this via the `_comment` query parameter (beware the proper encoding). For example `/api/parts/123?_comment=This%20is%20a%20change%20comment`.

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230816213201 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add table for API tokens';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE api_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, valid_until DATETIME DEFAULT NULL, token VARCHAR(68) NOT NULL, level SMALLINT NOT NULL, last_time_used DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE INDEX UNIQ_2CAD560E5F37A13B (token), INDEX IDX_2CAD560EA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE api_tokens DROP FOREIGN KEY FK_2CAD560EA76ED395');
$this->addSql('DROP TABLE api_tokens');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TABLE api_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, valid_until DATETIME DEFAULT NULL, token VARCHAR(68) NOT NULL, level SMALLINT NOT NULL, last_time_used DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE api_tokens');
}
}

View file

@ -19,6 +19,10 @@ parameters:
symfony:
container_xml_path: '%rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml'
doctrine:
objectManagerLoader: tests/object-manager.php
allowNullablePropertyForRequiredField: true
checkUninitializedProperties: true
checkFunctionNameCase: true
@ -48,8 +52,11 @@ parameters:
ignoreErrors:
# Ignore errors caused by complex mapping with AbstractStructuralDBElement
- '#AbstractStructuralDBElement does not have a field named \$parent#'
- '#AbstractStructuralDBElement does not have a field named \$name#'
#- '#AbstractStructuralDBElement does not have a field named \$name#'
# Ignore errors related to the use of the ParametersTrait in Part entity
- '#expects .*PartParameter, .*AbstractParameter given.#'
- '#Part::getParameters\(\) should return .*AbstractParameter#'
- '#Part::getParameters\(\) should return .*AbstractParameter#'
# Ignore doctrine type mapping mismatch
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'

View file

@ -0,0 +1,119 @@
<?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\ApiPlatform;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Operation;
use App\Entity\Attachments\Attachment;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\PropertyInfo\Type;
/**
* This decorator adds the properties given by DocumentedAPIProperty attributes on the classes to the schema.
*/
#[AsDecorator('api_platform.json_schema.schema_factory')]
class AddDocumentedAPIPropertiesJSONSchemaFactory implements SchemaFactoryInterface
{
public function __construct(private readonly SchemaFactoryInterface $decorated)
{
}
public function buildSchema(
string $className,
string $format = 'json',
string $type = Schema::TYPE_OUTPUT,
Operation $operation = null,
Schema $schema = null,
array $serializerContext = null,
bool $forceCollection = false
): Schema {
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
//Check if there is are DocumentedAPIProperty attributes on the class
$reflectionClass = new \ReflectionClass($className);
$attributes = $reflectionClass->getAttributes(DocumentedAPIProperty::class);
foreach ($attributes as $attribute) {
/** @var DocumentedAPIProperty $api_property */
$api_property = $attribute->newInstance();
$this->addPropertyToSchema($schema, $api_property->schemaName, $api_property->property,
$api_property, $serializerContext ?? [], $format);
}
return $schema;
}
private function addPropertyToSchema(Schema $schema, string $definitionName, string $normalizedPropertyName, DocumentedAPIProperty $propertyMetadata, array $serializerContext, string $format): void
{
$version = $schema->getVersion();
$swagger = Schema::VERSION_SWAGGER === $version;
$propertySchema = [];
if (false === $propertyMetadata->writeable) {
$propertySchema['readOnly'] = true;
}
if (!$swagger && false === $propertyMetadata->readable) {
$propertySchema['writeOnly'] = true;
}
if (null !== $description = $propertyMetadata->description) {
$propertySchema['description'] = $description;
}
$deprecationReason = $propertyMetadata->deprecationReason;
// see https://github.com/json-schema-org/json-schema-spec/pull/737
if (!$swagger && null !== $deprecationReason) {
$propertySchema['deprecated'] = true;
}
if (!empty($default = $propertyMetadata->default)) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$propertySchema['default'] = $default;
}
if (!empty($example = $propertyMetadata->example)) {
$propertySchema['example'] = $example;
}
if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
$propertySchema['example'] = $propertySchema['default'];
}
$propertySchema['type'] = $propertyMetadata->type;
$propertySchema['nullable'] = $propertyMetadata->nullable;
$propertySchema = new \ArrayObject($propertySchema);
$schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
}
}

View file

@ -0,0 +1,67 @@
<?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\ApiPlatform;
/**
* When this attribute is applied to a class, an property will be added to the API documentation using the given parameters.
* This is useful for adding properties to the API documentation, that are not existing in the entity class itself,
* but get added by a normalizer.
*/
#[\Attribute(\Attribute::TARGET_CLASS| \Attribute::IS_REPEATABLE)]
final class DocumentedAPIProperty
{
public function __construct(
/**
* @param string $schemaName The name of the schema to add the property to (e.g. "Part-Read")
*/
public readonly string $schemaName,
/**
* @var string $property The name of the property to add to the schema
*/
public readonly string $property,
public readonly string $type = 'string',
public readonly bool $nullable = true,
/**
* @var string $description The description of the property
*/
public readonly ?string $description = null,
/**
* @var bool True if the property is readable, false otherwise
*/
public readonly bool $readable = true,
/**
* @var bool True if the property is writable, false otherwise
*/
public readonly bool $writeable = false,
/**
* @var string|null The deprecation reason of the property
*/
public readonly ?string $deprecationReason = null,
/** @var mixed The default value of this property */
public readonly mixed $default = null,
public readonly mixed $example = null,
)
{
}
}

View file

@ -0,0 +1,88 @@
<?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\ApiPlatform\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
class EntityFilter extends AbstractFilter
{
public function __construct(
ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null,
?array $properties = null,
?NameConverterInterface $nameConverter = null
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
if (
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass, true)
) {
return;
}
$metadata = $this->getClassMetadata($resourceClass);
$target_class = $metadata->getAssociationTargetClass($property);
//If it is not an association we can not filter the property
if (!$target_class) {
return;
}
$elements = $this->filter_helper->valueToEntityArray($value, $target_class);
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder
->andWhere(sprintf('o.%s IN (:%s)', $property, $parameterName))
->setParameter($parameterName, $elements);
}
public function getDescription(string $resourceClass): array
{
return $this->filter_helper->getDescription($this->properties);
}
}

View file

@ -0,0 +1,104 @@
<?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\ApiPlatform\Filter;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PropertyInfo\Type;
class EntityFilterHelper
{
public function __construct(private NodesListBuilder $nodesListBuilder,
private EntityManagerInterface $entityManager)
{
}
public function valueToEntityArray(string $value, string $target_class): array
{
//Convert value to IDs:
$elements = [];
//Split the given value by comm
foreach (explode(',', $value) as $id) {
if (trim($id) === '') {
continue;
}
//Check if the given value ends with a plus, then we want to include all direct children
$include_children = false;
$include_recursive = false;
if (str_ends_with($id, '++')) { //Plus Plus means include all children recursively
$id = substr($id, 0, -2);
$include_recursive = true;
} elseif (str_ends_with($id, '+')) {
$id = substr($id, 0, -1);
$include_children = true;
}
//Get a (shallow) reference to the entitity
$element = $this->entityManager->getReference($target_class, (int) $id);
$elements[] = $element;
//If $element is not structural we are done
if (!is_a($element, AbstractStructuralDBElement::class)) {
continue;
}
//Get the recursive list of children
if ($include_recursive) {
$elements = array_merge($elements, $this->nodesListBuilder->getChildrenFlatList($element));
} elseif ($include_children) {
$elements = array_merge($elements, $element->getChildren()->toArray());
}
}
return $elements;
}
public function getDescription(array $properties): array
{
if (!$properties) {
return [];
}
$description = [];
foreach ($properties as $property => $strategy) {
$description["$property"] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter using a comma seperated list of element IDs. Use + to include all direct children and ++ to include all children recursively.',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;
}
}

View file

@ -0,0 +1,80 @@
<?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\ApiPlatform\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyInfo\Type;
final class LikeFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
// Otherwise filter is applied to order and page as well
if (
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass)
) {
return;
}
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder
->andWhere(sprintf('o.%s LIKE :%s', $property, $parameterName))
->setParameter($parameterName, $value);
}
public function getDescription(string $resourceClass): array
{
if (!$this->properties) {
return [];
}
$description = [];
foreach ($this->properties as $property => $strategy) {
$description["$property"] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter using a LIKE SQL expression. Use % as wildcard for multiple characters and _ for single characters. For example, to search for all items containing foo, use foo. To search for all items starting with foo, use foo%. To search for all items ending with foo, use %foo',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;
}
}

View file

@ -0,0 +1,83 @@
<?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\ApiPlatform\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\StorageLocation;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
class PartStoragelocationFilter extends AbstractFilter
{
public function __construct(
ManagerRegistry $managerRegistry,
private readonly EntityFilterHelper $filter_helper,
LoggerInterface $logger = null,
?array $properties = null,
?NameConverterInterface $nameConverter = null
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
//Do not check for mapping here, as we are using a virtual property
if (
!$this->isPropertyEnabled($property, $resourceClass)
) {
return;
}
$elements = $this->filter_helper->valueToEntityArray($value, StorageLocation::class);
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder
->leftJoin('o.partLots', 'partLots')
->andWhere(sprintf('partLots.storage_location IN (:%s)', $parameterName))
->setParameter($parameterName, $elements);
}
public function getDescription(string $resourceClass): array
{
return $this->filter_helper->getDescription($this->properties);
}
}

View file

@ -0,0 +1,72 @@
<?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\ApiPlatform;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use App\Entity\Attachments\Attachment;
use App\Entity\Parameters\AbstractParameter;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
/**
* API Platform has problems with single table inheritance, as it assumes that they all have different endpoints.
* This decorator fixes this problem by using the parent class for the metadata collection.
*/
#[AsDecorator('api_platform.metadata.resource.metadata_collection_factory')]
class FixInheritanceMappingMetadataFacory implements ResourceMetadataCollectionFactoryInterface
{
private const SINGLE_INHERITANCE_ENTITY_CLASSES = [
Attachment::class,
AbstractParameter::class,
];
private array $cache = [];
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated)
{
}
public function create(string $resourceClass): ResourceMetadataCollection
{
//If we already have a cached value, we can return it
if (isset($this->cache[$resourceClass])) {
return $this->decorated->create($this->cache[$resourceClass]);
}
//Check if the resourceClass is a single inheritance class, then we can use the parent class to access it
foreach (self::SINGLE_INHERITANCE_ENTITY_CLASSES as $class) {
if (is_a($resourceClass, $class, true)) {
$this->cache[$resourceClass] = $class;
break;
}
}
//If it was not found in the list of single inheritance classes, we can use the original class
if (!isset($this->cache[$resourceClass])) {
$this->cache[$resourceClass] = $resourceClass;
}
return $this->decorated->create($this->cache[$resourceClass] ?? $resourceClass);
}
}

View file

@ -0,0 +1,50 @@
<?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\ApiPlatform;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\Model\SecurityScheme;
use ApiPlatform\OpenApi\OpenApi;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('api_platform.openapi.factory')]
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
public function __construct(private readonly OpenApiFactoryInterface $decorated)
{
}
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
$securitySchemes['access_token'] = new SecurityScheme(
type: 'http',
description: 'Use an API token to authenticate',
name: 'Authorization',
scheme: 'bearer',
);
return $openApi;
}
}

View file

@ -0,0 +1,66 @@
<?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\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\State\PartDBInfoProvider;
/**
* This class is used to provide various information about the system.
*/
#[ApiResource(
uriTemplate: '/info.{_format}',
description: 'Basic information about Part-DB like version, title, etc.',
operations: [new Get(openapiContext: ['summary' => 'Get basic information about the installed Part-DB instance.'])],
provider: PartDBInfoProvider::class
)]
#[ApiFilter(PropertyFilter::class)]
class PartDBInfo
{
public function __construct(
/** The installed Part-DB version */
public readonly string $version,
/** The Git branch name of the Part-DB version (or null, if not installed via git) */
public readonly string|null $git_branch,
/** The Git branch commit of the Part-DB version (or null, if not installed via git) */
public readonly string|null $git_commit,
/** The name of this Part-DB instance */
public readonly string $title,
/** The banner, shown on homepage (markdown encoded) */
public readonly string $banner,
/** The configured default URI for Part-DB */
public readonly string $default_uri,
/** The global timezone of this Part-DB */
public readonly string $global_timezone,
/** The base currency of Part-DB, used as internal representation of monetary values */
public readonly string $base_currency,
/** The configured default language of Part-DB */
public readonly string $global_locale,
) {
}
}

View file

@ -30,7 +30,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\UserSystem\Group;
@ -85,7 +85,7 @@ class ConvertBBCodeCommand extends Command
return [
Part::class => ['description', 'comment'],
AttachmentType::class => ['comment'],
Storelocation::class => ['comment'],
StorageLocation::class => ['comment'],
Project::class => ['comment'],
Category::class => ['comment'],
Manufacturer::class => ['comment'],

View file

@ -55,6 +55,7 @@ final class PermissionsConfiguration implements ConfigurationInterface
->scalarNode('name')->end()
->scalarNode('label')->end()
->scalarNode('bit')->end()
->scalarNode('apiTokenRole')->defaultNull()->end()
->arrayNode('alsoSet')
->beforeNormalization()->castToArray()->end()->scalarPrototype()->end();

View file

@ -22,9 +22,9 @@ declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parts\Storelocation;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parts\StorageLocation;
use App\Form\AdminPages\StorelocationAdminForm;
use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
@ -39,24 +39,24 @@ use Symfony\Component\Routing\Annotation\Route;
* @see \App\Tests\Controller\AdminPages\StorelocationControllerTest
*/
#[Route(path: '/store_location')]
class StorelocationController extends BaseAdminController
class StorageLocationController extends BaseAdminController
{
protected string $entity_class = Storelocation::class;
protected string $entity_class = StorageLocation::class;
protected string $twig_template = 'admin/storelocation_admin.html.twig';
protected string $form_class = StorelocationAdminForm::class;
protected string $route_base = 'store_location';
protected string $attachment_class = StorelocationAttachment::class;
protected ?string $parameter_class = StorelocationParameter::class;
protected string $attachment_class = StorageLocationAttachment::class;
protected ?string $parameter_class = StorageLocationParameter::class;
#[Route(path: '/{id}', name: 'store_location_delete', methods: ['DELETE'])]
public function delete(Request $request, Storelocation $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
public function delete(Request $request, StorageLocation $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
{
return $this->_delete($request, $entity, $recursionHelper);
}
#[Route(path: '/{id}/edit/{timestamp}', requirements: ['id' => '\d+'], name: 'store_location_edit')]
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
public function edit(Storelocation $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
public function edit(StorageLocation $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
{
return $this->_edit($entity, $request, $em, $timestamp);
}
@ -64,7 +64,7 @@ class StorelocationController extends BaseAdminController
#[Route(path: '/new', name: 'store_location_new')]
#[Route(path: '/{id}/clone', name: 'store_location_clone')]
#[Route(path: '/')]
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Storelocation $entity = null): Response
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?StorageLocation $entity = null): Response
{
return $this->_new($request, $em, $importer, $entity);
}
@ -76,7 +76,7 @@ class StorelocationController extends BaseAdminController
}
#[Route(path: '/{id}/export', name: 'store_location_export')]
public function exportEntity(Storelocation $entity, EntityExporter $exporter, Request $request): Response
public function exportEntity(StorageLocation $entity, EntityExporter $exporter, Request $request): Response
{
return $this->_exportEntity($entity, $exporter, $request);
}

View file

@ -25,6 +25,7 @@ namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\Parts\Part;
use App\Services\Misc\GitVersionInfo;
use App\Services\System\BannerHelper;
use App\Services\System\UpdateAvailableManager;
use Doctrine\ORM\EntityManagerInterface;
use const DIRECTORY_SEPARATOR;
@ -38,29 +39,11 @@ use Symfony\Contracts\Cache\CacheInterface;
class HomepageController extends AbstractController
{
public function __construct(protected CacheInterface $cache, protected KernelInterface $kernel, protected DataTableFactory $dataTable)
public function __construct(private readonly DataTableFactory $dataTable, private readonly BannerHelper $bannerHelper)
{
}
public function getBanner(): string
{
$banner = $this->getParameter('partdb.banner');
if (!is_string($banner)) {
throw new \RuntimeException('The parameter "partdb.banner" must be a string.');
}
if (empty($banner)) {
$banner_path = $this->kernel->getProjectDir()
.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md';
$tmp = file_get_contents($banner_path);
if (false === $tmp) {
throw new \RuntimeException('The banner file could not be read.');
}
$banner = $tmp;
}
return $banner;
}
#[Route(path: '/', name: 'homepage')]
public function homepage(Request $request, GitVersionInfo $versionInfo, EntityManagerInterface $entityManager,
@ -96,7 +79,7 @@ class HomepageController extends AbstractController
}
return $this->render('homepage.html.twig', [
'banner' => $this->getBanner(),
'banner' => $this->bannerHelper->getBanner(),
'git_branch' => $versionInfo->getGitBranchName(),
'git_commit' => $versionInfo->getGitCommitHash(),
'show_first_steps' => $show_first_steps,

View file

@ -28,7 +28,7 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project;
@ -201,8 +201,8 @@ class PartController extends AbstractController
}
$store_id = $request->get('storelocation', null);
$storelocation = $store_id ? $em->find(Storelocation::class, $store_id) : null;
if ($storelocation instanceof Storelocation && $new_part->getPartLots()->isEmpty()) {
$storelocation = $store_id ? $em->find(StorageLocation::class, $store_id) : null;
if ($storelocation instanceof StorageLocation && $new_part->getPartLots()->isEmpty()) {
$partLot = new PartLot();
$partLot->setStorageLocation($storelocation);
$partLot->setInstockUnknown(true);

View file

@ -29,7 +29,7 @@ use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException;
use App\Form\Filters\PartFilterType;
@ -214,7 +214,7 @@ class PartListsController extends AbstractController
}
#[Route(path: '/store_location/{id}/parts', name: 'part_list_store_location')]
public function showStorelocation(Storelocation $storelocation, Request $request): Response
public function showStorelocation(StorageLocation $storelocation, Request $request): Response
{
$this->denyAccessUnlessGranted('@storelocations.read');
@ -226,7 +226,7 @@ class PartListsController extends AbstractController
$this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value'));
}, [
'entity' => $storelocation,
'repo' => $this->entityManager->getRepository(Storelocation::class),
'repo' => $this->entityManager->getRepository(StorageLocation::class),
]
);
}

View file

@ -27,7 +27,7 @@ use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Services\Trees\ToolsTreeBuilder;
use App\Services\Trees\TreeViewGenerator;
@ -80,10 +80,10 @@ class TreeController extends AbstractController
#[Route(path: '/location/{id}', name: 'tree_location')]
#[Route(path: '/locations', name: 'tree_location_root')]
public function locationTree(?Storelocation $location = null): JsonResponse
public function locationTree(?StorageLocation $location = null): JsonResponse
{
if ($this->isGranted('@parts.read') && $this->isGranted('@storelocations.read')) {
$tree = $this->treeGenerator->getTreeView(Storelocation::class, $location, 'list_parts_root');
$tree = $this->treeGenerator->getTreeView(StorageLocation::class, $location, 'list_parts_root');
} else {
return new JsonResponse("Access denied", Response::HTTP_FORBIDDEN);
}

View file

@ -34,7 +34,7 @@ use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
@ -102,7 +102,7 @@ class TypeaheadController extends AbstractController
'device' => ProjectParameter::class,
'footprint' => FootprintParameter::class,
'manufacturer' => ManufacturerParameter::class,
'storelocation' => StorelocationParameter::class,
'storelocation' => StorageLocationParameter::class,
'supplier' => SupplierParameter::class,
'attachment_type' => AttachmentTypeParameter::class,
'group' => GroupParameter::class,

View file

@ -23,6 +23,8 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Attachments\Attachment;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\U2FKey;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey;
@ -39,6 +41,8 @@ use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticator
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -395,4 +399,99 @@ class UserSettingsController extends AbstractController
],
]);
}
/**
* @return Response
*/
#[Route('/api_token/create', name: 'user_api_token_create')]
public function addApiToken(Request $request, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('@api.manage_tokens');
//When user change its settings, he should be logged in fully.
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$token = new ApiToken();
if (!$this->getUser() instanceof User) {
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
$token->setUser($this->getUser());
$secret = null;
$form = $this->createFormBuilder($token)
->add('name', TextType::class, [
'label' => 'api_tokens.name',
])
->add('level', EnumType::class, [
'class' => ApiTokenLevel::class,
'label' => 'api_tokens.access_level',
'help' => 'api_tokens.access_level.help',
'choice_label' => fn (ApiTokenLevel $level) => $level->getTranslationKey(),
])
->add('valid_until', DateTimeType::class, [
'label' => 'api_tokens.expiration_date',
'widget' => 'single_text',
'help' => 'api_tokens.expiration_date.help',
'required' => false,
'html5' => true
])
->add('submit', SubmitType::class, [
'label' => 'save',
])
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($token);
$entityManager->flush();
$secret = $token->getToken();
}
return $this->render('users/api_token_create.html.twig', [
'token' => $token,
'form' => $form,
'secret' => $secret,
]);
}
#[Route(path: '/api_token/delete', name: 'user_api_tokens_delete', methods: ['DELETE'])]
public function apiTokenRemove(Request $request, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('@api.manage_tokens');
//When user change its settings, he should be logged in fully.
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->getUser();
if (!$user instanceof User) {
throw new RuntimeException('This controller only works only for Part-DB User objects!');
}
if (!$this->isCsrfTokenValid('delete'.$user->getID(), $request->request->get('_token'))) {
$this->addFlash('error', 'csfr_invalid');
return $this->redirectToRoute('user_settings');
}
//Extract the token id from the request
$token_id = $request->request->getInt('token_id');
$token = $entityManager->find(ApiToken::class, $token_id);
if ($token === null) {
$this->addFlash('error', 'tfa_u2f.u2f_delete.not_existing');
return $this->redirectToRoute('user_settings');
}
//User can only delete its own API tokens
if ($token->getUser() !== $user) {
$this->addFlash('error', 'tfa_u2f.u2f_delete.access_denied');
return $this->redirectToRoute('user_settings');
}
//Do the actual deletion
$entityManager->remove($token);
$entityManager->flush();
$this->addFlash('success', 'api_tokens.deleted');
return $this->redirectToRoute('user_settings');
}
}

View file

@ -0,0 +1,97 @@
<?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\DataFixtures;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class APITokenFixtures extends Fixture implements DependentFixtureInterface
{
public const TOKEN_READONLY = 'tcp_readonly';
public const TOKEN_EDIT = 'tcp_edit';
public const TOKEN_ADMIN = 'tcp_admin';
public const TOKEN_FULL = 'tcp_full';
public const TOKEN_EXPIRED = 'tcp_expired';
public function load(ObjectManager $manager): void
{
/** @var User $admin_user */
$admin_user = $this->getReference(UserFixtures::ADMIN);
$read_only_token = new ApiToken();
$read_only_token->setUser($admin_user);
$read_only_token->setLevel(ApiTokenLevel::READ_ONLY);
$read_only_token->setName('read-only');
$this->setTokenSecret($read_only_token, self::TOKEN_READONLY);
$manager->persist($read_only_token);
$editor_token = new ApiToken();
$editor_token->setUser($admin_user);
$editor_token->setLevel(ApiTokenLevel::EDIT);
$editor_token->setName('edit');
$this->setTokenSecret($editor_token, self::TOKEN_EDIT);
$manager->persist($editor_token);
$admin_token = new ApiToken();
$admin_token->setUser($admin_user);
$admin_token->setLevel(ApiTokenLevel::ADMIN);
$admin_token->setName('admin');
$this->setTokenSecret($admin_token, self::TOKEN_ADMIN);
$manager->persist($admin_token);
$full_token = new ApiToken();
$full_token->setUser($admin_user);
$full_token->setLevel(ApiTokenLevel::FULL);
$full_token->setName('full');
$this->setTokenSecret($full_token, self::TOKEN_FULL);
$manager->persist($full_token);
$expired_token = new ApiToken();
$expired_token->setUser($admin_user);
$expired_token->setLevel(ApiTokenLevel::FULL);
$expired_token->setName('expired');
$expired_token->setValidUntil(new \DateTimeImmutable('-1 day'));
$this->setTokenSecret($expired_token, self::TOKEN_EXPIRED);
$manager->persist($expired_token);
$manager->flush();
}
private function setTokenSecret(ApiToken $token, string $secret): void
{
//Access private property
$reflection = new \ReflectionClass($token);
$property = $reflection->getProperty('token');
$property->setValue($token, $secret);
}
public function getDependencies(): array
{
return [UserFixtures::class];
}
}

View file

@ -29,7 +29,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
@ -51,7 +51,7 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface
{
//Reset autoincrement
$types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class,
MeasurementUnit::class, Storelocation::class, Supplier::class,];
MeasurementUnit::class, StorageLocation::class, Supplier::class,];
foreach ($types as $type) {
$this->createNodesForClass($type, $manager);

View file

@ -49,7 +49,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
@ -94,14 +94,14 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$part->setCategory($manager->find(Category::class, 1));
$partLot1 = new PartLot();
$partLot1->setAmount(1.0);
$partLot1->setStorageLocation($manager->find(Storelocation::class, 1));
$partLot1->setStorageLocation($manager->find(StorageLocation::class, 1));
$part->addPartLot($partLot1);
$partLot2 = new PartLot();
$partLot2->setExpirationDate(new DateTime());
$partLot2->setComment('Test');
$partLot2->setNeedsRefill(true);
$partLot2->setStorageLocation($manager->find(Storelocation::class, 3));
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
$part->addPartLot($partLot2);
$orderdetail = new Orderdetail();

View file

@ -31,6 +31,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserFixtures extends Fixture implements DependentFixtureInterface
{
public const ADMIN = 'user-admin';
public function __construct(protected UserPasswordHasherInterface $encoder, protected EntityManagerInterface $em)
{
}
@ -50,6 +52,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$admin->setNeedPwChange(false);
$admin->setGroup($this->getReference(GroupFixtures::ADMINS));
$manager->persist($admin);
$this->addReference(self::ADMIN, $admin);
$user = new User();
$user->setName('user');

View file

@ -37,7 +37,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Services\Trees\NodesListBuilder;
@ -118,7 +118,7 @@ class PartFilter implements FilterInterface
$this->lotCount = new IntConstraint('COUNT(partLots)');
$this->lessThanDesired = new LessThanDesiredConstraint();
$this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location');
$this->storelocation = new EntityConstraint($nodesListBuilder, StorageLocation::class, 'partLots.storage_location');
$this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill');
$this->lotUnknownAmount = new BooleanConstraint('partLots.instock_unknown');
$this->lotExpirationDate = new DateTimeConstraint('partLots.expiration_date');

View file

@ -34,7 +34,7 @@ use Doctrine\ORM\Tools\Pagination\Paginator;
use Omines\DataTablesBundle\Adapter\Doctrine\Event\ORMAdapterQueryEvent;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapterEvents;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
@ -147,7 +147,7 @@ final class PartsDataTable implements DataTableTypeInterface
$tmp = [];
foreach ($context->getPartLots() as $lot) {
//Ignore lots without storelocation
if (!$lot->getStorageLocation() instanceof Storelocation) {
if (!$lot->getStorageLocation() instanceof StorageLocation) {
continue;
}
$tmp[] = sprintf(

View file

@ -22,6 +22,19 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Repository\AttachmentRepository;
use App\EntityListeners\AttachmentDeleteListener;
use Doctrine\DBAL\Types\Types;
@ -29,6 +42,7 @@ use App\Entity\Base\AbstractNamedDBElement;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use function in_array;
use InvalidArgumentException;
@ -42,13 +56,38 @@ use LogicException;
#[ORM\Entity(repositoryClass: AttachmentRepository::class)]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'class_name', type: 'string')]
#[ORM\DiscriminatorMap(['PartDB\Part' => 'PartAttachment', 'Part' => 'PartAttachment', 'PartDB\Device' => 'ProjectAttachment', 'Device' => 'ProjectAttachment', 'AttachmentType' => 'AttachmentTypeAttachment', 'Category' => 'CategoryAttachment', 'Footprint' => 'FootprintAttachment', 'Manufacturer' => 'ManufacturerAttachment', 'Currency' => 'CurrencyAttachment', 'Group' => 'GroupAttachment', 'MeasurementUnit' => 'MeasurementUnitAttachment', 'Storelocation' => 'StorelocationAttachment', 'Supplier' => 'SupplierAttachment', 'User' => 'UserAttachment', 'LabelProfile' => 'LabelAttachment'])]
#[ORM\DiscriminatorMap(['PartDB\Part' => PartAttachment::class, 'Part' => PartAttachment::class,
'PartDB\Device' => ProjectAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
'User' => UserAttachment::class, 'LabelProfile' => LabelAttachment::class])]
#[ORM\EntityListeners([AttachmentDeleteListener::class])]
#[ORM\Table(name: '`attachments`')]
#[ORM\Index(name: 'attachments_idx_id_element_id_class_name', columns: ['id', 'element_id', 'class_name'])]
#[ORM\Index(name: 'attachments_idx_class_name_id', columns: ['class_name', 'id'])]
#[ORM\Index(name: 'attachment_name_idx', columns: ['name'])]
#[ORM\Index(name: 'attachment_element_idx', columns: ['class_name', 'element_id'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@attachments.list_attachments")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['attachment:read', 'attachment:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['attachment:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
example: '/media/part/2/bc547-6508afa5a79c8.pdf')]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.')]
#[ApiFilter(LikeFilter::class, properties: ["name"])]
#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
abstract class Attachment extends AbstractNamedDBElement
{
/**
@ -96,24 +135,33 @@ abstract class Attachment extends AbstractNamedDBElement
* @var string the name of this element
*/
#[Assert\NotBlank(message: 'validator.attachment.name_not_blank')]
#[Groups(['simple', 'extended', 'full'])]
#[Groups(['simple', 'extended', 'full', 'attachment:read', 'attachment:write'])]
protected string $name = '';
/**
* ORM mapping is done in subclasses (like PartAttachment).
* @phpstan-param T|null $element
*/
#[Groups(['attachment:read:standalone', 'attachment:write'])]
protected ?AttachmentContainingDBElement $element = null;
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['attachment:read', 'attachment_write'])]
protected bool $show_in_table = false;
#[Assert\NotNull(message: 'validator.attachment.must_not_be_null')]
#[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'attachments_with_type')]
#[ORM\JoinColumn(name: 'type_id', nullable: false)]
#[Selectable()]
#[Groups(['attachment:read', 'attachment_write'])]
protected ?AttachmentType $attachment_type = null;
#[Groups(['attachment:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['attachment:read'])]
protected ?\DateTimeInterface $lastModified = null;
public function __construct()
{
//parent::__construct();
@ -141,6 +189,7 @@ abstract class Attachment extends AbstractNamedDBElement
* @return bool * true if the file extension is a picture extension
* * otherwise false
*/
#[Groups(['attachment:read'])]
public function isPicture(): bool
{
if ($this->isExternal()) {
@ -165,6 +214,8 @@ abstract class Attachment extends AbstractNamedDBElement
* Check if this attachment is a 3D model and therefore can be directly shown to user.
* If the attachment is external, false is returned (3D Models must be internal).
*/
#[Groups(['attachment:read'])]
#[SerializedName('3d_model')]
public function is3DModel(): bool
{
//We just assume that 3D Models are internally saved, otherwise we get problems loading them.
@ -182,6 +233,7 @@ abstract class Attachment extends AbstractNamedDBElement
*
* @return bool true, if the file is saved externally
*/
#[Groups(['attachment:read'])]
public function isExternal(): bool
{
//When path is empty, this attachment can not be external
@ -201,6 +253,8 @@ abstract class Attachment extends AbstractNamedDBElement
*
* @return bool true, if the file is secure
*/
#[Groups(['attachment:read'])]
#[SerializedName('private')]
public function isSecure(): bool
{
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
@ -215,6 +269,7 @@ abstract class Attachment extends AbstractNamedDBElement
*
* @return bool true if the attachment is using a builtin file
*/
#[Groups(['attachment:read'])]
public function isBuiltIn(): bool
{
return static::checkIfBuiltin($this->path);
@ -261,6 +316,8 @@ abstract class Attachment extends AbstractNamedDBElement
* The URL to the external file, or the path to the built-in file.
* Returns null, if the file is not external (and not builtin).
*/
#[Groups(['attachment:read'])]
#[SerializedName('url')]
public function getURL(): ?string
{
if (!$this->isExternal() && !$this->isBuiltIn()) {
@ -411,6 +468,8 @@ abstract class Attachment extends AbstractNamedDBElement
*
* @return Attachment
*/
#[Groups(['attachment:write'])]
#[SerializedName('url')]
public function setURL(?string $url): self
{
//Only set if the URL is not empty

View file

@ -22,6 +22,20 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Parts\Footprint;
use App\Repository\StructuralDBElementRepository;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractStructuralDBElement;
@ -30,6 +44,7 @@ use App\Validator\Constraints\ValidFileFilter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
@ -41,6 +56,32 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: '`attachment_types`')]
#[ORM\Index(name: 'attachment_types_idx_name', columns: ['name'])]
#[ORM\Index(name: 'attachment_types_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@attachment_types.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['attachment_type:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/attachment_types/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of an attachment type.'],
security: 'is_granted("@attachment_types.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)
],
normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class AttachmentType extends AbstractStructuralDBElement
{
#[ORM\OneToMany(targetEntity: AttachmentType::class, mappedBy: 'parent', cascade: ['persist'])]
@ -49,10 +90,18 @@ class AttachmentType extends AbstractStructuralDBElement
#[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['attachment_type:read', 'attachment_type:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
/**
* @var string A comma separated list of file types, which are allowed for attachment files.
* Must be in the format of <pre><input type=file></pre> accept attribute
* (See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers).
*/
#[ORM\Column(type: Types::TEXT)]
#[ValidFileFilter]
#[Groups(['attachment_type:read', 'attachment_type:write'])]
protected string $filetype_filter = '';
/**
@ -61,10 +110,12 @@ class AttachmentType extends AbstractStructuralDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: AttachmentTypeAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['attachment_type:read', 'attachment_type:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: AttachmentTypeAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['attachment_type:read', 'attachment_type:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, AttachmentTypeParameter>
@ -72,6 +123,7 @@ class AttachmentType extends AbstractStructuralDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: AttachmentTypeParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['attachment_type:read', 'attachment_type:write'])]
protected Collection $parameters;
/**
@ -80,6 +132,12 @@ class AttachmentType extends AbstractStructuralDBElement
#[ORM\OneToMany(targetEntity: Attachment::class, mappedBy: 'attachment_type')]
protected Collection $attachments_with_type;
#[Groups(['attachment_type:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['attachment_type:read'])]
protected ?\DateTimeInterface $lastModified = null;
public function __construct()
{
$this->children = new ArrayCollection();

View file

@ -36,9 +36,8 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
class MeasurementUnitAttachment extends Attachment
{
final public const ALLOWED_ELEMENT_CLASS = MeasurementUnit::class;
/**
* @var Manufacturer|null the element this attachment is associated with
*/
#[ORM\ManyToOne(targetEntity: MeasurementUnit::class, inversedBy: 'attachments')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
protected ?AttachmentContainingDBElement $element = null;

View file

@ -22,24 +22,24 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* An attachment attached to a measurement unit element.
* @extends Attachment<Storelocation>
* @extends Attachment<StorageLocation>
*/
#[UniqueEntity(['name', 'attachment_type', 'element'])]
#[ORM\Entity]
class StorelocationAttachment extends Attachment
class StorageLocationAttachment extends Attachment
{
final public const ALLOWED_ELEMENT_CLASS = Storelocation::class;
final public const ALLOWED_ELEMENT_CLASS = StorageLocation::class;
/**
* @var Storelocation|null the element this attachment is associated with
* @var StorageLocation|null the element this attachment is associated with
*/
#[ORM\ManyToOne(targetEntity: Storelocation::class, inversedBy: 'attachments')]
#[ORM\ManyToOne(targetEntity: StorageLocation::class, inversedBy: 'attachments')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
protected ?AttachmentContainingDBElement $element = null;
}

View file

@ -40,24 +40,29 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\MappedSuperclass]
abstract class AbstractCompany extends AbstractPartsContainingDBElement
{
#[Groups(['company:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['company:read'])]
protected ?\DateTimeInterface $lastModified = null;
/**
* @var string The address of the company
*/
#[Groups(['full'])]
#[Groups(['full', 'company:read', 'company:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $address = '';
/**
* @var string The phone number of the company
*/
#[Groups(['full'])]
#[Groups(['full', 'company:read', 'company:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $phone_number = '';
/**
* @var string The fax number of the company
*/
#[Groups(['full'])]
#[Groups(['full', 'company:read', 'company:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $fax_number = '';
@ -65,7 +70,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* @var string The email address of the company
*/
#[Assert\Email]
#[Groups(['full'])]
#[Groups(['full', 'company:read', 'company:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $email_address = '';
@ -73,12 +78,15 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* @var string The website of the company
*/
#[Assert\Url]
#[Groups(['full'])]
#[Groups(['full', 'company:read', 'company:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $website = '';
#[Groups(['company:read', 'company:write'])]
protected string $comment = '';
/**
* @var string
* @var string The link to the website of an article. Use %PARTNUMBER% as placeholder for the part number.
*/
#[ORM\Column(type: Types::STRING)]
protected string $auto_product_url = '';

View file

@ -34,9 +34,10 @@ use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@ -45,7 +46,7 @@ use App\Entity\UserSystem\Group;
use App\Entity\Parts\Manufacturer;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\PartLot;
use App\Entity\PriceInformations\Currency;
use App\Entity\Parts\MeasurementUnit;
@ -66,14 +67,14 @@ use Symfony\Component\Serializer\Annotation\Groups;
* Every database table which are managed with this class (or a subclass of it)
* must have the table row "id"!! The ID is the unique key to identify the elements.
*/
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorelocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => Storelocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => 'App\Entity\Parts\AbstractParameter', 'supplier' => Supplier::class, 'user' => User::class])]
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
#[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)]
abstract class AbstractDBElement implements JsonSerializable
{
/** @var int|null The Identification number for this part. This value is unique for the element in this table.
* Null if the element is not saved to DB yet.
*/
#[Groups(['full'])]
#[Groups(['full', 'api:basic:read'])]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\Id]
#[ORM\GeneratedValue]

View file

@ -40,10 +40,10 @@ abstract class AbstractNamedDBElement extends AbstractDBElement implements Named
use TimestampTrait;
/**
* @var string the name of this element
* @var string The name of this element
*/
#[Assert\NotBlank]
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'api:basic:read', 'api:basic:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $name = '';

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Base;
use ApiPlatform\Metadata\ApiProperty;
use App\Entity\Attachments\Attachment;
use App\Entity\Parameters\AbstractParameter;
use App\Repository\StructuralDBElementRepository;
@ -31,6 +32,7 @@ use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Parameters\ParametersTrait;
use App\Validator\Constraints\NoneOfItsChildren;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Valid;
use function count;
@ -73,7 +75,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
final public const PATH_DELIMITER_ARROW = ' → ';
/**
* @var string The comment info for this element
* @var string The comment info for this element as markdown
*/
#[Groups(['full', 'import'])]
#[ORM\Column(type: Types::TEXT)]
@ -219,7 +221,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
}
/**
* Get the comment of the element.
* Get the comment of the element as markdown encoded string.
*
* @return string the comment
@ -261,6 +263,8 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
*
* @return string the full path (incl. the name of this element), delimited by $delimiter
*/
#[Groups(['api:basic:read'])]
#[SerializedName('full_path')]
public function getFullPath(string $delimiter = self::PATH_DELIMITER_ARROW): string
{
if ($this->full_path_strings === []) {

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Base;
use ApiPlatform\Metadata\ApiProperty;
use Doctrine\DBAL\Types\Types;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
@ -36,6 +37,7 @@ trait TimestampTrait
* @var \DateTimeInterface|null the date when this element was modified the last time
*/
#[Groups(['extended', 'full'])]
#[ApiProperty(writable: false)]
#[ORM\Column(name: 'last_modified', type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
protected ?\DateTimeInterface $lastModified = null;
@ -43,6 +45,7 @@ trait TimestampTrait
* @var \DateTimeInterface|null the date when this element was created
*/
#[Groups(['extended', 'full'])]
#[ApiProperty(writable: false)]
#[ORM\Column(name: 'datetime_added', type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
protected ?\DateTimeInterface $addedDate = null;

View file

@ -22,7 +22,7 @@ namespace App\Entity\LabelSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
enum LabelSupportedElement: string
{
@ -39,7 +39,7 @@ enum LabelSupportedElement: string
return match ($this) {
self::PART => Part::class,
self::PART_LOT => PartLot::class,
self::STORELOCATION => Storelocation::class,
self::STORELOCATION => StorageLocation::class,
};
}
}

View file

@ -36,7 +36,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;

View file

@ -52,7 +52,7 @@ use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractDBElement;
@ -69,14 +69,14 @@ use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\UserSystem\Group;
@ -166,8 +166,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
return MeasurementUnitParameter::class;
case Part::class:
return PartParameter::class;
case Storelocation::class:
return StorelocationParameter::class;
case StorageLocation::class:
return StorageLocationParameter::class;
case Supplier::class:
return SupplierParameter::class;
@ -196,8 +196,8 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
return MeasurementUnitAttachment::class;
case Part::class:
return PartAttachment::class;
case Storelocation::class:
return StorelocationAttachment::class;
case StorageLocation::class:
return StorageLocationAttachment::class;
case Supplier::class:
return SupplierAttachment::class;
case User::class:

View file

@ -30,7 +30,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
@ -81,7 +81,7 @@ enum LogTargetType: int
self::GROUP => Group::class,
self::MANUFACTURER => Manufacturer::class,
self::PART => Part::class,
self::STORELOCATION => Storelocation::class,
self::STORELOCATION => StorageLocation::class,
self::SUPPLIER => Supplier::class,
self::PART_LOT => PartLot::class,
self::CURRENCY => Currency::class,

View file

@ -41,6 +41,18 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\ParameterRepository;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -48,7 +60,9 @@ use App\Entity\Base\AbstractNamedDBElement;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use LogicException;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use function sprintf;
@ -56,11 +70,29 @@ use function sprintf;
#[ORM\Entity(repositoryClass: ParameterRepository::class)]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'smallint')]
#[ORM\DiscriminatorMap([0 => 'CategoryParameter', 1 => 'CurrencyParameter', 2 => 'ProjectParameter', 3 => 'FootprintParameter', 4 => 'GroupParameter', 5 => 'ManufacturerParameter', 6 => 'MeasurementUnitParameter', 7 => 'PartParameter', 8 => 'StorelocationParameter', 9 => 'SupplierParameter', 10 => 'AttachmentTypeParameter'])]
#[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class,
3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class,
6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class,
9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class])]
#[ORM\Table('parameters')]
#[ORM\Index(name: 'parameter_name_idx', columns: ['name'])]
#[ORM\Index(name: 'parameter_group_idx', columns: ['param_group'])]
#[ORM\Index(name: 'parameter_type_element_idx', columns: ['type', 'element_id'])]
#[ApiResource(
shortName: 'Parameter',
operations: [
new Get(security: 'is_granted("read", object)'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['parameter:read', 'parameter:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['parameter:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(LikeFilter::class, properties: ["name", "symbol", "unit", "group", "value_text"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(RangeFilter::class, properties: ["value_min", "value_typical", "value_max"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
abstract class AbstractParameter extends AbstractNamedDBElement
{
/**
@ -72,7 +104,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
* @var string The mathematical symbol for this specification. Can be rendered pretty later. Should be short
*/
#[Assert\Length(max: 20)]
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $symbol = '';
@ -82,7 +114,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
#[Assert\Type(['float', null])]
#[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')]
#[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')]
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter_write'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_min = null;
@ -90,7 +122,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
* @var float|null the typical value of this property
*/
#[Assert\Type([null, 'float'])]
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_typical = null;
@ -106,21 +138,21 @@ abstract class AbstractParameter extends AbstractNamedDBElement
/**
* @var string The unit in which the value values are given (e.g. V)
*/
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $unit = '';
/**
* @var string a text value for the given property
*/
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $value_text = '';
/**
* @var string the group this parameter belongs to
*/
#[Groups(['full'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])]
#[ORM\Column(type: Types::STRING, name: 'param_group')]
protected string $group = '';
@ -129,6 +161,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
*
* @var AbstractDBElement|null the element to which this parameter belongs to
*/
#[Groups(['parameter:read:standalone', 'parameter:write'])]
protected ?AbstractDBElement $element = null;
public function __construct()
@ -158,6 +191,8 @@ abstract class AbstractParameter extends AbstractNamedDBElement
* Return a formatted string version of the values of the string.
* Based on the set values it can return something like this: 34 V (12 V ... 50 V) [Text].
*/
#[Groups(['parameter:read', 'full'])]
#[SerializedName('formatted')]
public function getFormattedValue(): string
{
//If we just only have text value, return early

View file

@ -43,20 +43,20 @@ namespace App\Entity\Parameters;
use App\Repository\ParameterRepository;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[UniqueEntity(fields: ['name', 'group', 'element'])]
#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class StorelocationParameter extends AbstractParameter
class StorageLocationParameter extends AbstractParameter
{
final public const ALLOWED_ELEMENT_CLASS = Storelocation::class;
final public const ALLOWED_ELEMENT_CLASS = StorageLocation::class;
/**
* @var Storelocation the element this para is associated with
* @var StorageLocation the element this para is associated with
*/
#[ORM\ManyToOne(targetEntity: Storelocation::class, inversedBy: 'parameters')]
#[ORM\ManyToOne(targetEntity: StorageLocation::class, inversedBy: 'parameters')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
protected ?AbstractDBElement $element = null;
}

View file

@ -22,8 +22,21 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\Parts\CategoryRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection;
@ -45,6 +58,32 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: '`categories`')]
#[ORM\Index(name: 'category_idx_name', columns: ['name'])]
#[ORM\Index(name: 'category_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@categories.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['category:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/categories/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a category.'],
security: 'is_granted("@categories.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Category::class)
],
normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Category extends AbstractPartsContainingDBElement
{
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
@ -53,61 +92,66 @@ class Category extends AbstractPartsContainingDBElement
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['category:read', 'category:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[Groups(['category:read', 'category:write'])]
protected string $comment = '';
/**
* @var string
* @var string The hint which is shown as hint under the partname field, when a part is created in this category.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $partname_hint = '';
/**
* @var string
* @var string The regular expression which is used to validate the partname of a part in this category.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $partname_regex = '';
/**
* @var bool
* @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet).
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_footprints = false;
/**
* @var bool
* @var bool Set to true, if the manufacturers should be disabled for parts this category (not implemented yet).
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_manufacturers = false;
/**
* @var bool
* @var bool Set to true, if the autodatasheets should be disabled for parts this category (not implemented yet).
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_autodatasheets = false;
/**
* @var bool
* @var bool Set to true, if the properties should be disabled for parts this category (not implemented yet).
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_properties = false;
/**
* @var string
* @var string The default description for parts in this category.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $default_description = '';
/**
* @var string
* @var string The default comment for parts in this category.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'category:read', 'category:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $default_comment = '';
@ -115,23 +159,30 @@ class Category extends AbstractPartsContainingDBElement
* @var Collection<int, CategoryAttachment>
*/
#[Assert\Valid]
#[Groups(['full'])]
#[Groups(['full', 'category:read', 'category:write'])]
#[ORM\OneToMany(targetEntity: CategoryAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: CategoryAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['category:read', 'category:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, CategoryParameter>
*/
#[Assert\Valid]
#[Groups(['full'])]
#[Groups(['full', 'category:read', 'category:write'])]
#[ORM\OneToMany(targetEntity: CategoryParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
protected Collection $parameters;
#[Groups(['category:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['category:read'])]
protected ?\DateTimeInterface $lastModified = null;
public function getPartnameHint(): string
{
return $this->partname_hint;

View file

@ -22,6 +22,20 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\Parts\FootprintRepository;
@ -32,6 +46,7 @@ use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parameters\FootprintParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
@ -43,26 +58,59 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table('`footprints`')]
#[ORM\Index(name: 'footprint_idx_name', columns: ['name'])]
#[ORM\Index(name: 'footprint_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@footprints.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a footprint.'],
security: 'is_granted("@footprints.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)
],
normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Footprint extends AbstractPartsContainingDBElement
{
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['footprint:read', 'footprint:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
#[ORM\OrderBy(['name' => 'ASC'])]
protected Collection $children;
#[Groups(['footprint:read', 'footprint:write'])]
protected string $comment = '';
/**
* @var Collection<int, FootprintAttachment>
*/
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: FootprintAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['footprint:read', 'footprint:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: FootprintAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['footprint:read', 'footprint:write'])]
protected ?Attachment $master_picture_attachment = null;
/**
@ -70,6 +118,7 @@ class Footprint extends AbstractPartsContainingDBElement
*/
#[ORM\ManyToOne(targetEntity: FootprintAttachment::class)]
#[ORM\JoinColumn(name: 'id_footprint_3d')]
#[Groups(['footprint:read', 'footprint:write'])]
protected ?FootprintAttachment $footprint_3d = null;
/** @var Collection<int, FootprintParameter>
@ -77,8 +126,15 @@ class Footprint extends AbstractPartsContainingDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: FootprintParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['footprint:read', 'footprint:write'])]
protected Collection $parameters;
#[Groups(['footprint:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['footprint:read'])]
protected ?\DateTimeInterface $lastModified = null;
/****************************************
* Getters
****************************************/

View file

@ -27,6 +27,7 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* This class represents a reference to a info provider inside a part.
@ -37,19 +38,23 @@ class InfoProviderReference
/** @var string|null The key referencing the provider used to get this part, or null if it was not provided by a data provider */
#[Column(type: 'string', nullable: true)]
#[Groups(['provider_reference:read'])]
private ?string $provider_key = null;
/** @var string|null The id of this part inside the provider system or null if the part was not provided by a data provider */
#[Column(type: 'string', nullable: true)]
#[Groups(['provider_reference:read'])]
private ?string $provider_id = null;
/**
* @var string|null The url of this part inside the provider system or null if this info is not existing
*/
#[Column(type: 'string', nullable: true)]
#[Groups(['provider_reference:read'])]
private ?string $provider_url = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true, options: ['default' => null])]
#[Groups(['provider_reference:read'])]
private ?\DateTimeInterface $last_updated = null;
/**

View file

@ -22,6 +22,19 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\Parts\ManufacturerRepository;
@ -32,6 +45,7 @@ use App\Entity\Base\AbstractCompany;
use App\Entity\Parameters\ManufacturerParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
@ -43,10 +57,38 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table('`manufacturers`')]
#[ORM\Index(name: 'manufacturer_name', columns: ['name'])]
#[ORM\Index(name: 'manufacturer_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@manufacturers.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/manufacturers/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a manufacturer.'],
security: 'is_granted("@manufacturers.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Manufacturer extends AbstractCompany
{
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
@ -59,10 +101,14 @@ class Manufacturer extends AbstractCompany
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: ManufacturerAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: ManufacturerAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, ManufacturerParameter>
@ -70,6 +116,8 @@ class Manufacturer extends AbstractCompany
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: ManufacturerParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $parameters;
public function __construct()
{

View file

@ -22,6 +22,19 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\Parts\MeasurementUnitRepository;
@ -48,6 +61,32 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: '`measurement_units`')]
#[ORM\Index(name: 'unit_idx_name', columns: ['name'])]
#[ORM\Index(name: 'unit_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@measurement_units.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/footprints/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a MeasurementUnit.'],
security: 'is_granted("@measurement_units.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)
],
normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class MeasurementUnit extends AbstractPartsContainingDBElement
{
/**
@ -55,15 +94,18 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* or m (for meters).
*/
#[Assert\Length(max: 10)]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(type: Types::STRING, name: 'unit', nullable: true)]
protected ?string $unit = null;
#[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected string $comment = '';
/**
* @var bool Determines if the amount value associated with this unit should be treated as integer.
* Set to false, to measure continuous sizes likes masses or lengths.
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(type: Types::BOOLEAN, name: 'is_integer')]
protected bool $is_integer = false;
@ -72,7 +114,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* Useful for sizes like meters. For this the unit must be set
*/
#[Assert\Expression('this.isUseSIPrefix() == false or this.getUnit() != null', message: 'validator.measurement_unit.use_si_prefix_needs_unit')]
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(type: Types::BOOLEAN, name: 'use_si_prefix')]
protected bool $use_si_prefix = false;
@ -82,6 +124,8 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
/**
@ -90,10 +134,12 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: MeasurementUnitAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: MeasurementUnitAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, MeasurementUnitParameter>
@ -101,8 +147,15 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: MeasurementUnitParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected Collection $parameters;
#[Groups(['measurement_unit:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['measurement_unit:read'])]
protected ?\DateTimeInterface $lastModified = null;
/**
* @return string
*/

View file

@ -22,6 +22,24 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\Filter\PartStoragelocationFilter;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\PartRepository;
use Doctrine\DBAL\Types\Types;
@ -60,6 +78,30 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'parts_idx_datet_name_last_id_needs', columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'])]
#[ORM\Index(name: 'parts_idx_name', columns: ['name'])]
#[ORM\Index(name: 'parts_idx_ipn', columns: ['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'],
'openapi_definition_name' => 'Read',
], security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
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'],
denormalizationContext: ['groups' => ['part:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])]
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "tags", "manufacturer_product_number"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
#[DocumentedAPIProperty(schemaName: 'Part-Read', property: 'total_instock', type: 'number', nullable: false,
description: 'The total amount of this part in stock (sum of all part lots).')]
class Part extends AttachmentContainingDBElement
{
use AdvancedPropertyTrait;
@ -74,7 +116,7 @@ class Part extends AttachmentContainingDBElement
/** @var Collection<int, PartParameter>
*/
#[Assert\Valid]
#[Groups(['full'])]
#[Groups(['full', 'part:read', 'part:write'])]
#[ORM\OneToMany(targetEntity: PartParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
protected Collection $parameters;
@ -94,7 +136,7 @@ class Part extends AttachmentContainingDBElement
* @var Collection<int, PartAttachment>
*/
#[Assert\Valid]
#[Groups(['full'])]
#[Groups(['full', 'part:read', 'part:write'])]
#[ORM\OneToMany(targetEntity: PartAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
protected Collection $attachments;
@ -105,8 +147,15 @@ class Part extends AttachmentContainingDBElement
#[Assert\Expression('value == null or value.isPicture()', message: 'part.master_attachment.must_be_picture')]
#[ORM\ManyToOne(targetEntity: PartAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['part:read', 'part:write'])]
protected ?Attachment $master_picture_attachment = null;
#[Groups(['part:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['part:read'])]
protected ?\DateTimeInterface $lastModified = null;
public function __construct()
{
$this->attachments = new ArrayCollection();

View file

@ -22,6 +22,19 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Repository\PartLotRepository;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -50,6 +63,23 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(name: 'part_lots_idx_instock_un_expiration_id_part', columns: ['instock_unknown', 'expiration_date', 'id_part'])]
#[ORM\Index(name: 'part_lots_idx_needs_refill', columns: ['needs_refill'])]
#[ValidPartLot]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['part_lot:read', 'part_lot:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
#[ApiFilter(RangeFilter::class, properties: ['amount'])]
#[ApiFilter(OrderFilter::class, properties: ['description', 'comment', 'addedDate', 'lastModified'])]
class PartLot extends AbstractDBElement implements TimeStampableInterface, NamedElementInterface
{
use TimestampTrait;
@ -57,14 +87,14 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* @var string A short description about this lot, shown in table
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var string a comment stored with this lot
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
@ -72,38 +102,38 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
* @var \DateTimeInterface|null Set a time until when the lot must be used.
* Set to null, if the lot can be used indefinitely.
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::DATETIME_MUTABLE, name: 'expiration_date', nullable: true)]
protected ?\DateTimeInterface $expiration_date = null;
/**
* @var Storelocation|null The storelocation of this lot
* @var StorageLocation|null The storelocation of this lot
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[ORM\ManyToOne(targetEntity: Storelocation::class, fetch: 'EAGER')]
#[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\ManyToOne(targetEntity: StorageLocation::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'id_store_location')]
#[Selectable()]
protected ?Storelocation $storage_location = null;
protected ?StorageLocation $storage_location = null;
/**
* @var bool If this is set to true, the instock amount is marked as not known
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $instock_unknown = false;
/**
* @var float For continuous sizes (length, volume, etc.) the instock is saved here.
* @var float The amount of parts in this lot. For integer-quantities this value is rounded to the next integer.
*/
#[Assert\PositiveOrZero]
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::FLOAT)]
protected float $amount = 0.0;
/**
* @var bool determines if this lot was manually marked for refilling
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $needs_refill = false;
@ -113,6 +143,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
#[Assert\NotNull]
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'partLots')]
#[ORM\JoinColumn(name: 'id_part', nullable: false, onDelete: 'CASCADE')]
#[Groups(['part_lot:read:standalone', 'part_lot:write'])]
protected ?Part $part = null;
/**
@ -120,6 +151,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
*/
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
#[Groups(['part_lot:read', 'part_lot:write'])]
protected ?User $owner = null;
public function __clone()
@ -207,9 +239,9 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Gets the storage location, where this part lot is stored.
*
* @return Storelocation|null The store location where this part is stored
* @return StorageLocation|null The store location where this part is stored
*/
public function getStorageLocation(): ?Storelocation
public function getStorageLocation(): ?StorageLocation
{
return $this->storage_location;
}
@ -217,7 +249,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Sets the storage location, where this part lot is stored.
*/
public function setStorageLocation(?Storelocation $storage_location): self
public function setStorageLocation(?StorageLocation $storage_location): self
{
$this->storage_location = $storage_location;

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
use ApiPlatform\Metadata\ApiProperty;
use App\Entity\Parts\InfoProviderReference;
use Doctrine\DBAL\Types\Types;
use App\Entity\Parts\Part;
@ -37,22 +38,22 @@ trait AdvancedPropertyTrait
/**
* @var bool Determines if this part entry needs review (for example, because it is work in progress)
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $needs_review = false;
/**
* @var string a comma separated list of tags, associated with the part
* @var string A comma separated list of tags, associated with the part
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $tags = '';
/**
* @var float|null how much a single part unit weighs in grams
* @var float|null How much a single part unit weighs in grams
*/
#[Assert\PositiveOrZero]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $mass = null;
@ -60,14 +61,15 @@ trait AdvancedPropertyTrait
* @var string|null The internal part number of the part
*/
#[Assert\Length(max: 100)]
#[Groups(['extended', 'full', 'import'])]
#[ORM\Column(type: Types::STRING, length: 100, nullable: true, unique: true)]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
protected ?string $ipn = null;
/**
* @var InfoProviderReference The reference to the info provider, that provided the information about this part
*/
#[ORM\Embedded(class: InfoProviderReference::class, columnPrefix: 'provider_reference_')]
#[Groups(['full', 'part:read'])]
protected InfoProviderReference $providerReference;
/**

View file

@ -35,14 +35,14 @@ trait BasicPropertyTrait
/**
* @var string A text describing what this part does
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var string A comment/note related to this part
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
@ -55,7 +55,7 @@ trait BasicPropertyTrait
/**
* @var bool true, if the part is marked as favorite
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $favorite = false;
@ -65,7 +65,7 @@ trait BasicPropertyTrait
*/
#[Assert\NotNull(message: 'validator.select_valid_category')]
#[Selectable()]
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', "part:read", "part:write"])]
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(name: 'id_category', nullable: false)]
protected ?Category $category = null;
@ -73,7 +73,7 @@ trait BasicPropertyTrait
/**
* @var Footprint|null The footprint of this part (e.g. DIP8)
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\ManyToOne(targetEntity: Footprint::class)]
#[ORM\JoinColumn(name: 'id_footprint')]
#[Selectable()]

View file

@ -36,10 +36,10 @@ use Symfony\Component\Validator\Constraints as Assert;
trait InstockTrait
{
/**
* @var Collection|PartLot[] A list of part lots where this part is stored
* @var Collection<int, PartLot> A list of part lots where this part is stored
*/
#[Assert\Valid]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\OneToMany(targetEntity: PartLot::class, mappedBy: 'part', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['amount' => 'DESC'])]
protected Collection $partLots;
@ -49,14 +49,14 @@ trait InstockTrait
* Given in the partUnit.
*/
#[Assert\PositiveOrZero]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::FLOAT)]
protected float $minamount = 0;
/**
* @var ?MeasurementUnit the unit in which the part's amount is measured
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\ManyToOne(targetEntity: MeasurementUnit::class)]
#[ORM\JoinColumn(name: 'id_part_unit')]
protected ?MeasurementUnit $partUnit = null;

View file

@ -39,31 +39,31 @@ trait ManufacturerTrait
/**
* @var Manufacturer|null The manufacturer of this part
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\ManyToOne(targetEntity: Manufacturer::class)]
#[ORM\JoinColumn(name: 'id_manufacturer')]
#[Selectable()]
protected ?Manufacturer $manufacturer = null;
/**
* @var string the url to the part on the manufacturer's homepage
* @var string The url to the part on the manufacturer's homepage
*/
#[Assert\Url]
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';
/**
* @var string The product number used by the manufacturer. If this is set to "", the name field is used.
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $manufacturer_product_number = '';
/**
* @var ManufacturingStatus|null The production status of this part. Can be one of the specified ones.
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, enumType: ManufacturingStatus::class)]
protected ?ManufacturingStatus $manufacturing_status = ManufacturingStatus::NOT_SET;

View file

@ -36,10 +36,10 @@ use Doctrine\ORM\Mapping as ORM;
trait OrderTrait
{
/**
* @var Collection<int, Orderdetail> the details about how and where you can order this part
* @var Collection<int, Orderdetail> The details about how and where you can order this part
*/
#[Assert\Valid]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\OneToMany(targetEntity: Orderdetail::class, mappedBy: 'part', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['supplierpartnr' => 'ASC'])]
protected Collection $orderdetails;

View file

@ -9,12 +9,10 @@ use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
trait ProjectTrait
{
/**
* @var Collection<ProjectBOMEntry> $project_bom_entries
*/
/**
* @var Collection<ProjectBOMEntry> $project_bom_entries
*/
@ -42,6 +40,7 @@ trait ProjectTrait
* Checks whether this part represents the builds of a project
* @return bool True if it represents the builds, false if not
*/
#[Groups(['part:read'])]
public function isProjectBuildPart(): bool
{
return $this->built_project !== null;

View file

@ -22,14 +22,27 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Repository\Parts\StorelocationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\UserSystem\User;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@ -38,13 +51,39 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* This entity represents a storage location, where parts can be stored.
* @extends AbstractPartsContainingDBElement<StorelocationAttachment, StorelocationParameter>
* @extends AbstractPartsContainingDBElement<StorageLocationAttachment, StorageLocationParameter>
*/
#[ORM\Entity(repositoryClass: StorelocationRepository::class)]
#[ORM\Table('`storelocations`')]
#[ORM\Index(name: 'location_idx_name', columns: ['name'])]
#[ORM\Index(name: 'location_idx_parent_name', columns: ['parent_id', 'name'])]
class Storelocation extends AbstractPartsContainingDBElement
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@storelocations.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['location:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/storage_locations/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a storage location.'],
security: 'is_granted("@storelocations.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
],
normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class StorageLocation extends AbstractPartsContainingDBElement
{
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
#[ORM\OrderBy(['name' => 'ASC'])]
@ -52,40 +91,47 @@ class Storelocation extends AbstractPartsContainingDBElement
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['location:read', 'location:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[Groups(['location:read', 'location:write'])]
protected string $comment = '';
/**
* @var MeasurementUnit|null The measurement unit, which parts can be stored in here
*/
#[ORM\ManyToOne(targetEntity: MeasurementUnit::class)]
#[ORM\JoinColumn(name: 'storage_type_id')]
#[Groups(['location:read', 'location:write'])]
protected ?MeasurementUnit $storage_type = null;
/** @var Collection<int, StorelocationParameter>
/** @var Collection<int, StorageLocationParameter>
*/
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: StorelocationParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: StorageLocationParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['location:read', 'location:write'])]
protected Collection $parameters;
/**
* @var bool
* @var bool When this attribute is set, it is not possible to add additional parts or increase the instock of existing parts.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'location:read', 'location:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $is_full = false;
/**
* @var bool
* @var bool When this property is set, only one part (but many instock) is allowed to be stored in this store location.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'location:read', 'location:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $only_single_part = false;
/**
* @var bool
* @var bool When this property is set, it is only possible to increase the instock of parts, that are already stored here.
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'location:read', 'location:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $limit_to_existing_parts = false;
@ -95,25 +141,35 @@ class Storelocation extends AbstractPartsContainingDBElement
#[Assert\Expression('this.getOwner() == null or this.getOwner().isAnonymousUser() === false', message: 'validator.part_lot.owner_must_not_be_anonymous')]
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
#[Groups(['location:read', 'location:write'])]
protected ?User $owner = null;
/**
* @var bool If this is set to true, only parts lots, which are owned by the same user as the store location are allowed to be stored here.
*/
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
#[Groups(['location:read', 'location:write'])]
protected bool $part_owner_must_match = false;
/**
* @var Collection<int, StorelocationAttachment>
* @var Collection<int, StorageLocationAttachment>
*/
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: StorelocationAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OneToMany(targetEntity: StorageLocationAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['location:read', 'location:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: StorelocationAttachment::class)]
#[ORM\ManyToOne(targetEntity: StorageLocationAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['location:read', 'location:write'])]
protected ?Attachment $master_picture_attachment = null;
#[Groups(['location:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['location:read'])]
protected ?\DateTimeInterface $lastModified = null;
/********************************************************************************
*
* Getters
@ -186,7 +242,7 @@ class Storelocation extends AbstractPartsContainingDBElement
/**
* Sets the owner of this storage location
*/
public function setOwner(?User $owner): Storelocation
public function setOwner(?User $owner): StorageLocation
{
$this->owner = $owner;
return $this;
@ -203,7 +259,7 @@ class Storelocation extends AbstractPartsContainingDBElement
/**
* If this is set to true, only parts lots, which are owned by the same user as the store location are allowed to be stored here.
*/
public function setPartOwnerMustMatch(bool $part_owner_must_match): Storelocation
public function setPartOwnerMustMatch(bool $part_owner_must_match): StorageLocation
{
$this->part_owner_must_match = $part_owner_must_match;
return $this;

View file

@ -22,6 +22,19 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\Parts\SupplierRepository;
@ -49,6 +62,30 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table('`suppliers`')]
#[ORM\Index(name: 'supplier_idx_name', columns: ['name'])]
#[ORM\Index(name: 'supplier_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@suppliers.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/suppliers/{id}/children.{_format}',
operations: [new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a supplier'],
security: 'is_granted("@manufacturers.read")')],
uriVariables: [
'id' => new Link(fromClass: Supplier::class, fromProperty: 'children')
],
normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Supplier extends AbstractCompany
{
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
@ -57,11 +94,10 @@ class Supplier extends AbstractCompany
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['supplier:read', 'supplier:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
/**
* @var Collection<int, Orderdetail>|Orderdetail[]
*/
/**
* @var Collection<int, Orderdetail>|Orderdetail[]
*/
@ -78,7 +114,7 @@ class Supplier extends AbstractCompany
protected ?Currency $default_currency = null;
/**
* @var BigDecimal|null the shipping costs that have to be paid, when ordering via this supplier
* @var BigDecimal|null The shipping costs that have to be paid, when ordering via this supplier
*/
#[Groups(['extended', 'full', 'import'])]
#[ORM\Column(name: 'shipping_costs', nullable: true, type: 'big_decimal', precision: 11, scale: 5)]
@ -91,10 +127,14 @@ class Supplier extends AbstractCompany
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: SupplierAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['supplier:read', 'supplier:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: SupplierAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['supplier:read', 'supplier:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, SupplierParameter>
@ -102,6 +142,8 @@ class Supplier extends AbstractCompany
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: SupplierParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['supplier:read', 'supplier:write'])]
#[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $parameters;
/**

View file

@ -22,8 +22,20 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\CurrencyRepository;
use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\CurrencyAttachment;
@ -49,6 +61,32 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'currencies')]
#[ORM\Index(name: 'currency_idx_name', columns: ['name'])]
#[ORM\Index(name: 'currency_idx_parent_name', columns: ['parent_id', 'name'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@currencies.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['currency:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/currencies/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a currency.'],
security: 'is_granted("@currencies.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Currency::class)
],
normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "iso_code"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Currency extends AbstractStructuralDBElement
{
final public const PRICE_SCALE = 5;
@ -59,14 +97,19 @@ class Currency extends AbstractStructuralDBElement
*/
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[BigDecimalPositive()]
#[Groups(['currency:read', 'currency:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?BigDecimal $exchange_rate = null;
#[Groups(['currency:read', 'currency:write'])]
protected string $comment = "";
/**
* @var string the 3-letter ISO code of the currency
*/
#[Assert\Currency]
#[Assert\NotBlank]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'currency:read', 'currency:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $iso_code = "";
@ -76,6 +119,8 @@ class Currency extends AbstractStructuralDBElement
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['currency:read', 'currency:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
/**
@ -84,10 +129,12 @@ class Currency extends AbstractStructuralDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: CurrencyAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['currency:read', 'currency:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: CurrencyAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['currency:read', 'currency:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, CurrencyParameter>
@ -95,6 +142,7 @@ class Currency extends AbstractStructuralDBElement
#[Assert\Valid]
#[ORM\OneToMany(targetEntity: CurrencyParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['currency:read', 'currency:write'])]
protected Collection $parameters;
/** @var Collection<int, Pricedetail>
@ -102,6 +150,12 @@ class Currency extends AbstractStructuralDBElement
#[ORM\OneToMany(targetEntity: Pricedetail::class, mappedBy: 'currency')]
protected Collection $pricedetails;
#[Groups(['currency:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['currency:read'])]
protected ?\DateTimeInterface $lastModified = null;
public function __construct()
{
$this->children = new ArrayCollection();
@ -136,6 +190,7 @@ class Currency extends AbstractStructuralDBElement
/**
* Returns the inverse exchange rate (how many of the current currency the base unit is worth).
*/
#[Groups(['currency:read'])]
public function getInverseExchangeRate(): ?BigDecimal
{
$tmp = $this->getExchangeRate();

View file

@ -23,6 +23,19 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
@ -46,51 +59,80 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\HasLifecycleCallbacks]
#[ORM\Table('`orderdetails`')]
#[ORM\Index(name: 'orderdetails_supplier_part_nr', columns: ['supplierpartnr'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['orderdetail:read', 'orderdetail:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['orderdetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/parts/{id}/orderdetails.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the orderdetails of a part.'],
security: 'is_granted("@parts.read")')
],
uriVariables: [
'id' => new Link(toProperty: 'part', fromClass: Part::class)
],
normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["supplierpartnr", "supplier_product_url"])]
#[ApiFilter(BooleanFilter::class, properties: ["obsolete"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['supplierpartnr', 'id', 'addedDate', 'lastModified'])]
class Orderdetail extends AbstractDBElement implements TimeStampableInterface, NamedElementInterface
{
use TimestampTrait;
#[Assert\Valid]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\OneToMany(targetEntity: Pricedetail::class, mappedBy: 'orderdetail', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['min_discount_quantity' => 'ASC'])]
protected Collection $pricedetails;
/**
* @var string
* @var string The order number of the part at the supplier
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::STRING)]
protected string $supplierpartnr = '';
/**
* @var bool
* @var bool True if this part is obsolete/not available anymore at the supplier
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $obsolete = false;
/**
* @var string
* @var string The URL to the product on the supplier's website
*/
#[Assert\Url]
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';
/**
* @var Part|null
* @var Part|null The part with which this orderdetail is associated
*/
#[Assert\NotNull]
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'orderdetails')]
#[Groups(['orderdetail:read:standalone', 'orderdetail:write'])]
#[ORM\JoinColumn(name: 'part_id', nullable: false, onDelete: 'CASCADE')]
protected ?Part $part = null;
/**
* @var Supplier|null
* @var Supplier|null The supplier of this orderdetail
*/
#[Assert\NotNull(message: 'validator.orderdetail.supplier_must_not_be_null')]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'orderdetails')]
#[ORM\JoinColumn(name: 'id_supplier')]
protected ?Supplier $supplier = null;

View file

@ -22,6 +22,17 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
@ -34,6 +45,7 @@ use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
@ -45,6 +57,18 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table('`pricedetails`')]
#[ORM\Index(name: 'pricedetails_idx_min_discount', columns: ['min_discount_quantity'])]
#[ORM\Index(name: 'pricedetails_idx_min_discount_price_qty', columns: ['min_discount_quantity', 'price_related_quantity'])]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@parts.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['pricedetail:read', 'pricedetail:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['pricedetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiFilter(PropertyFilter::class)]
class Pricedetail extends AbstractDBElement implements TimeStampableInterface
{
use TimestampTrait;
@ -54,7 +78,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
/**
* @var BigDecimal The price related to the detail. (Given in the selected currency)
*/
#[Groups(['extended', 'full'])]
#[Groups(['extended', 'full', 'pricedetail:read', 'pricedetail:write'])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5)]
#[BigDecimalPositive()]
protected BigDecimal $price;
@ -63,25 +87,25 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
* @var ?Currency The currency used for the current price information.
* If this is null, the global base unit is assumed
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
#[ORM\ManyToOne(targetEntity: Currency::class, inversedBy: 'pricedetails')]
#[ORM\JoinColumn(name: 'id_currency')]
#[Selectable()]
protected ?Currency $currency = null;
/**
* @var float
* @var float The amount/quantity for which the price is for (in part unit)
*/
#[Assert\Positive]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
#[ORM\Column(type: Types::FLOAT)]
protected float $price_related_quantity = 1.0;
/**
* @var float
* @var float The minimum amount/quantity, which is needed to get this discount (in part unit)
*/
#[Assert\Positive]
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
#[ORM\Column(type: Types::FLOAT)]
protected float $min_discount_quantity = 1.0;
@ -97,6 +121,7 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
#[Assert\NotNull]
#[ORM\ManyToOne(targetEntity: Orderdetail::class, inversedBy: 'pricedetails')]
#[ORM\JoinColumn(name: 'orderdetails_id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
protected ?Orderdetail $orderdetail = null;
public function __construct()
@ -167,6 +192,8 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
*
* @return BigDecimal the price as a bcmath string
*/
#[Groups(['pricedetail:read'])]
#[SerializedName('price_per_unit')]
public function getPricePerUnit(float|string|BigDecimal $multiplier = 1.0): BigDecimal
{
$tmp = BigDecimal::of($multiplier);
@ -228,6 +255,18 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
return $this->currency;
}
/**
* Returns the ISO code of the currency associated with this price information, or null if no currency is selected.
* Then the global base currency should be assumed.
* @return string|null
*/
#[Groups(['pricedetail:read'])]
#[SerializedName('currency_iso_code')]
public function getCurrencyISOCode(): ?string
{
return $this->currency?->getIsoCode();
}
/********************************************************************************
*
* Setters

View file

@ -22,8 +22,22 @@ declare(strict_types=1);
namespace App\Entity\ProjectSystem;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Parts\Category;
use App\Repository\Parts\DeviceRepository;
use App\Validator\Constraints\UniqueObjectCollection;
use Doctrine\DBAL\Types\Types;
@ -46,6 +60,31 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
*/
#[ORM\Entity(repositoryClass: DeviceRepository::class)]
#[ORM\Table(name: 'projects')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@projects.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['project:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/children.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a project.'],
security: 'is_granted("@projects.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Project::class)
],
normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Project extends AbstractStructuralDBElement
{
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
@ -54,8 +93,13 @@ class Project extends AbstractStructuralDBElement
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['project:read', 'project:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[Groups(['project:read', 'project:write'])]
protected string $comment = '';
#[Assert\Valid]
#[Groups(['extended', 'full'])]
#[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'project', cascade: ['persist', 'remove'], orphanRemoval: true)]
@ -70,7 +114,7 @@ class Project extends AbstractStructuralDBElement
* @var string|null The current status of the project
*/
#[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Groups(['extended', 'full'])]
#[Groups(['extended', 'full', 'project:read', 'project:write'])]
#[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;
@ -79,12 +123,13 @@ class Project extends AbstractStructuralDBElement
* @var Part|null The (optional) part that represents the builds of this project in the stock
*/
#[ORM\OneToOne(targetEntity: Part::class, mappedBy: 'built_project', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['project:read', 'project:write'])]
protected ?Part $build_part = null;
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $order_only_missing_parts = false;
#[Groups(['simple', 'extended', 'full'])]
#[Groups(['simple', 'extended', 'full', 'project:read', 'project:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $description = '';
@ -93,18 +138,27 @@ class Project extends AbstractStructuralDBElement
*/
#[ORM\OneToMany(targetEntity: ProjectAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['project:read', 'project:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: ProjectAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['project:read', 'project:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, ProjectParameter>
*/
#[ORM\OneToMany(targetEntity: ProjectParameter::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])]
#[Groups(['project:read', 'project:write'])]
protected Collection $parameters;
#[Groups(['project:read'])]
protected ?\DateTimeInterface $addedDate = null;
#[Groups(['project:read'])]
protected ?\DateTimeInterface $lastModified = null;
/********************************************************************************
*
* Getters

View file

@ -22,6 +22,18 @@ declare(strict_types=1);
namespace App\Entity\ProjectSystem;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -33,6 +45,7 @@ use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@ -42,18 +55,46 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity]
#[ORM\Table('project_bom_entries')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)', uriTemplate: '/project_bom_entries/{id}.{_format}',),
new GetCollection(security: 'is_granted("@projects.read")', uriTemplate: '/project_bom_entries.{_format}',),
new Post(securityPostDenormalize: 'is_granted("create", object)', uriTemplate: '/project_bom_entries.{_format}',),
new Patch(security: 'is_granted("edit", object)', uriTemplate: '/project_bom_entries/{id}.{_format}',),
new Delete(security: 'is_granted("delete", object)', uriTemplate: '/project_bom_entries/{id}.{_format}',),
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/projects/{id}/bom.{_format}',
operations: [
new GetCollection(openapiContext: ['summary' => 'Retrieves the BOM entries of the given project.'],
security: 'is_granted("@projects.read")')
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", 'mountnames'])]
#[ApiFilter(RangeFilter::class, properties: ['quantity'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified', 'quantity'])]
class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInterface
{
use TimestampTrait;
#[Assert\Positive]
#[ORM\Column(type: Types::FLOAT, name: 'quantity')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected float $quantity = 1.0;
/**
* @var string A comma separated list of the names, where this parts should be placed
*/
#[ORM\Column(type: Types::TEXT, name: 'mountnames')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected string $mountnames = '';
/**
@ -61,12 +102,14 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?string $name = null;
/**
* @var string An optional comment for this BOM entry
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected string $comment = '';
/**
@ -74,6 +117,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'bom_entries')]
#[ORM\JoinColumn(name: 'id_device')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Project $project = null;
/**
@ -81,6 +125,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')]
#[ORM\JoinColumn(name: 'id_part')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Part $part = null;
/**
@ -88,6 +133,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
*/
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?BigDecimal $price = null;
/**

View file

@ -0,0 +1,195 @@
<?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\UserSystem;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\TimestampTrait;
use App\Repository\UserSystem\ApiTokenRepository;
use App\State\CurrentApiTokenProvider;
use App\State\PartDBInfoProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints\NotBlank;
#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
#[ORM\Table(name: 'api_tokens')]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name', 'user'])]
#[ApiResource(
uriTemplate: '/tokens/current.{_format}',
description: 'A token used to authenticate API requests.',
operations: [new Get(openapiContext: ['summary' => 'Get information about the API token that is currently used.'])],
normalizationContext: ['groups' => ['token:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
provider: CurrentApiTokenProvider::class,
)]
#[ApiFilter(PropertyFilter::class)]
class ApiToken
{
use TimestampTrait;
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
protected int $id;
#[ORM\Column(type: Types::STRING)]
#[NotBlank]
#[Groups('token:read')]
protected string $name = '';
#[ORM\ManyToOne(inversedBy: 'api_tokens')]
#[Groups('token:read')]
private ?User $user = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups('token:read')]
private ?\DateTimeInterface $valid_until;
#[ORM\Column(length: 68, unique: true)]
private string $token;
#[ORM\Column(type: Types::SMALLINT, enumType: ApiTokenLevel::class)]
#[Groups('token:read')]
private ApiTokenLevel $level = ApiTokenLevel::READ_ONLY;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
#[Groups('token:read')]
private ?\DateTimeInterface $last_time_used = null;
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
{
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
$this->token = $tokenType->getTokenPrefix() . bin2hex(random_bytes(32));
//By default, tokens are valid for 1 year.
$this->valid_until = new \DateTime('+1 year');
}
public function getTokenType(): ApiTokenType
{
return ApiTokenType::getTypeFromToken($this->token);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): ApiToken
{
$this->user = $user;
return $this;
}
public function getValidUntil(): ?\DateTimeInterface
{
return $this->valid_until;
}
/**
* Checks if the token is still valid.
* @return bool
*/
public function isValid(): bool
{
return $this->valid_until === null || $this->valid_until > new \DateTime();
}
public function setValidUntil(?\DateTimeInterface $valid_until): ApiToken
{
$this->valid_until = $valid_until;
return $this;
}
public function getToken(): string
{
return $this->token;
}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): ApiToken
{
$this->name = $name;
return $this;
}
/**
* Gets the last time the token was used to authenticate or null if it was never used.
* @return \DateTimeInterface|null
*/
public function getLastTimeUsed(): ?\DateTimeInterface
{
return $this->last_time_used;
}
/**
* Sets the last time the token was used to authenticate.
* @param \DateTimeInterface|null $last_time_used
* @return ApiToken
*/
public function setLastTimeUsed(?\DateTimeInterface $last_time_used): ApiToken
{
$this->last_time_used = $last_time_used;
return $this;
}
public function getLevel(): ApiTokenLevel
{
return $this->level;
}
public function setLevel(ApiTokenLevel $level): ApiToken
{
$this->level = $level;
return $this;
}
/**
* Returns the last 4 characters of the token secret, which can be used to identify the token.
* @return string
*/
public function getLastTokenChars(): string
{
return substr($this->token, -4);
}
}

View file

@ -0,0 +1,73 @@
<?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\UserSystem;
enum ApiTokenLevel: int
{
private const ROLE_READ_ONLY = 'ROLE_API_READ_ONLY';
private const ROLE_EDIT = 'ROLE_API_EDIT';
private const ROLE_ADMIN = 'ROLE_API_ADMIN';
private const ROLE_FULL = 'ROLE_API_FULL';
/**
* The token can only read (non-sensitive) data.
*/
case READ_ONLY = 1;
/**
* The token can read and edit (non-sensitive) data.
*/
case EDIT = 2;
/**
* The token can do some administrative tasks (like viewing all log entries), but can not change passwords and create new tokens.
*/
case ADMIN = 3;
/**
* The token can do everything the user can do.
*/
case FULL = 4;
/**
* Returns the additional roles that the authenticated user should have when using this token.
* @return string[]
*/
public function getAdditionalRoles(): array
{
//The higher roles should always include the lower ones
return match ($this) {
self::READ_ONLY => [self::ROLE_READ_ONLY],
self::EDIT => [self::ROLE_READ_ONLY, self::ROLE_EDIT],
self::ADMIN => [self::ROLE_READ_ONLY, self::ROLE_EDIT, self::ROLE_ADMIN],
self::FULL => [self::ROLE_READ_ONLY, self::ROLE_EDIT, self::ROLE_ADMIN, self::ROLE_FULL],
};
}
/**
* Returns the translation key for the name of this token level.
* @return string
*/
public function getTranslationKey(): string
{
return 'api_token.level.' . strtolower($this->name);
}
}

View file

@ -0,0 +1,56 @@
<?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\UserSystem;
/**
* The type of ApiToken.
* The enum value is the prefix of the token. It must be 3 characters long.
*/
enum ApiTokenType: string
{
case PERSONAL_ACCESS_TOKEN = 'tcp';
/**
* Get the prefix of the token including the underscore
* @return string
*/
public function getTokenPrefix(): string
{
return $this->value . '_';
}
/**
* Get the type from the token prefix
* @param string $api_token
* @return ApiTokenType
*/
public static function getTypeFromToken(string $api_token): ApiTokenType
{
$parts = explode('_', $api_token);
if (count($parts) !== 2) {
throw new \InvalidArgumentException('Invalid token format');
}
return self::from($parts[0]);
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\UserSystem;
use ApiPlatform\Metadata\ApiResource;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Validator\Constraints\NoLockout;

View file

@ -22,6 +22,17 @@ declare(strict_types=1);
namespace App\Entity\UserSystem;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Repository\UserRepository;
@ -72,7 +83,20 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
#[ORM\AttributeOverrides([
new ORM\AttributeOverride(name: 'name', column: new ORM\Column(type: Types::STRING, length: 180, unique: true))
])]
#[ApiResource(
shortName: 'User',
operations: [
new Get(openapiContext: ['summary' => 'Get a specific user.'],
security: 'is_granted("read", object)'),
new GetCollection(openapiContext: ['summary' => 'Get all users defined in the system.'],
security: 'is_granted("@users.read")'),
],
normalizationContext: ['groups' => ['user:read'], 'openapi_definition_name' => 'Read'],
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "aboutMe"])]
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
#[NoLockout()]
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface,
BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface
@ -84,17 +108,26 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*/
final public const ID_ANONYMOUS = 1;
#[Groups(['user:read'])]
protected ?int $id = null;
#[Groups(['user:read'])]
protected ?\DateTimeInterface $lastModified = null;
#[Groups(['user:read'])]
protected ?\DateTimeInterface $addedDate = null;
/**
* @var bool Determines if the user is disabled (user can not log in)
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $disabled = false;
/**
* @var string|null The theme
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, name: 'config_theme', nullable: true)]
#[ValidTheme()]
protected ?string $theme = null;
@ -112,9 +145,9 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
protected string $instock_comment_w = '';
/**
* @var string A self-description of the user
* @var string A self-description of the user as markdown text
*/
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'user:read'])]
#[ORM\Column(type: Types::TEXT)]
protected string $aboutMe = '';
@ -133,10 +166,11 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @var Group|null the group this user belongs to
* DO NOT PUT A fetch eager here! Otherwise, you can not unset the group of a user! This seems to be some kind of bug in doctrine. Maybe this is fixed in future versions.
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'user:read'])]
#[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'users')]
#[ORM\JoinColumn(name: 'group_id')]
#[Selectable]
#[ApiProperty(readableLink: true, writableLink: false)]
protected ?Group $group = null;
/**
@ -149,7 +183,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @var string|null The timezone the user prefers
*/
#[Assert\Timezone]
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, name: 'config_timezone', nullable: true)]
protected ?string $timezone = '';
@ -157,7 +191,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @var string|null The language/locale the user prefers
*/
#[Assert\Language]
#[Groups(['full', 'import'])]
#[Groups(['full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, name: 'config_language', nullable: true)]
protected ?string $language = '';
@ -165,7 +199,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @var string|null The email address of the user
*/
#[Assert\Email]
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
protected ?string $email = '';
@ -173,33 +207,34 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @var bool True if the user wants to show his email address on his (public) profile
*/
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
#[Groups(['full', 'import', 'user:read'])]
protected bool $show_email_on_profile = false;
/**
* @var string|null The department the user is working
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
protected ?string $department = '';
/**
* @var string|null The last name of the User
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
protected ?string $last_name = '';
/**
* @var string|null The first name of the User
*/
#[Groups(['simple', 'extended', 'full', 'import'])]
#[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
protected ?string $first_name = '';
/**
* @var bool True if the user needs to change password after log in
*/
#[Groups(['extended', 'full', 'import'])]
#[Groups(['extended', 'full', 'import', 'user:read'])]
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $need_pw_change = true;
@ -211,6 +246,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
#[Assert\NotBlank]
#[Assert\Regex('/^[\w\.\+\-\$]+$/', message: 'user.invalid_username')]
#[Groups(['user:read'])]
protected string $name = '';
/**
@ -224,10 +260,12 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*/
#[ORM\OneToMany(mappedBy: 'element', targetEntity: UserAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])]
#[Groups(['user:read', 'user:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: UserAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['user:read', 'user:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var \DateTimeInterface|null The time when the backup codes were generated
@ -247,6 +285,12 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
#[ORM\OneToMany(mappedBy: 'user', targetEntity: WebauthnKey::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
protected Collection $webauthn_keys;
/**
* @var Collection<int, ApiToken>
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: ApiToken::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $api_tokens;
/**
* @var Currency|null The currency the user wants to see prices in.
* Dont use fetch=EAGER here, this will cause problems with setting the currency setting.
@ -284,6 +328,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
$this->permissions = new PermissionData();
$this->u2fKeys = new ArrayCollection();
$this->webauthn_keys = new ArrayCollection();
$this->api_tokens = new ArrayCollection();
}
/**
@ -501,6 +546,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*
* @return string a string with the full name of this user
*/
#[Groups(['user:read'])]
public function getFullName(bool $including_username = false): string
{
$tmp = $this->getFirstName();
@ -936,8 +982,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
return $this;
}
public function setSamlAttributes(array $attributes): void
{
//When mail attribute exists, set it
@ -967,4 +1011,34 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
$this->setEmail($attributes['urn:oid:1.2.840.113549.1.9.1'][0]);
}
}
/**
* Return all API tokens of the user.
* @return Collection<int, ApiToken>
*/
public function getApiTokens(): Collection
{
return $this->api_tokens;
}
/**
* Add an API token to the user.
* @param ApiToken $apiToken
* @return void
*/
public function addApiToken(ApiToken $apiToken): void
{
$apiToken->setUser($this);
$this->api_tokens->add($apiToken);
}
/**
* Remove an API token from the user.
* @param ApiToken $apiToken
* @return void
*/
public function removeApiToken(ApiToken $apiToken): void
{
$this->api_tokens->removeElement($apiToken);
}
}

View file

@ -0,0 +1,62 @@
<?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\EventListener;
use App\Services\LogSystem\EventCommentHelper;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
#[AsEventListener()]
class AddEditCommentRequestListener
{
public function __construct(private readonly EventCommentHelper $helper)
{
}
public function __invoke(RequestEvent $event)
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
//Do not add comment if the request is a GET request
if ($request->isMethod('GET')) {
return;
}
//Check if the user tries to access a /api/ endpoint, if not skip
if (!str_contains($request->getPathInfo(), '/api/')) {
return;
}
//Extract the comment from the query parameter
$comment = $request->query->getString('_comment', '');
if ($comment !== '') {
$this->helper->setMessage($comment);
}
}
}

View file

@ -32,7 +32,7 @@ use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\LabelAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parts\Manufacturer;
@ -85,7 +85,7 @@ class AttachmentFilterType extends AbstractType
'label_profile.label' => LabelAttachment::class,
'manufacturer.label' => Manufacturer::class,
'measurement_unit.label' => MeasurementUnit::class,
'storelocation.label' => StorelocationAttachment::class,
'storelocation.label' => StorageLocationAttachment::class,
'supplier.label' => SupplierAttachment::class,
'user.label' => UserAttachment::class,
]

View file

@ -49,7 +49,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;

View file

@ -29,7 +29,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\ChoiceConstraintType;
@ -191,7 +191,7 @@ class PartFilterType extends AbstractType
*/
$builder->add('storelocation', StructuralEntityConstraintType::class, [
'label' => 'storelocation.label',
'entity_class' => Storelocation::class
'entity_class' => StorageLocation::class
]);
$builder->add('minAmount', NumberConstraintType::class, [

View file

@ -50,7 +50,7 @@ use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parameters\StorelocationParameter;
use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
use Symfony\Component\Form\AbstractType;
@ -163,7 +163,7 @@ class ParameterType extends AbstractType
GroupParameter::class => 'group',
ManufacturerParameter::class => 'manufacturer',
MeasurementUnit::class => 'measurement_unit',
StorelocationParameter::class => 'storelocation',
StorageLocationParameter::class => 'storelocation',
SupplierParameter::class => 'supplier',
];

View file

@ -25,7 +25,7 @@ namespace App\Form\Part;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Form\Type\SIUnitType;
use App\Form\Type\StructuralEntityType;
use App\Form\Type\UserSelectType;
@ -54,7 +54,7 @@ class PartLotType extends AbstractType
]);
$builder->add('storage_location', StructuralEntityType::class, [
'class' => Storelocation::class,
'class' => StorageLocation::class,
'label' => 'part_lot.edit.location',
'required' => false,
'disable_not_selectable' => true,

View file

@ -22,7 +22,7 @@ declare(strict_types=1);
*/
namespace App\Form\Type;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityRepository;
@ -46,7 +46,7 @@ class PartLotSelectType extends AbstractType
$resolver->setDefaults([
'class' => PartLot::class,
'choice_label' => ChoiceList::label($this, static fn(PartLot $part_lot): string => ($part_lot->getStorageLocation() instanceof Storelocation ? $part_lot->getStorageLocation()->getFullPath() : '')
'choice_label' => ChoiceList::label($this, static fn(PartLot $part_lot): string => ($part_lot->getStorageLocation() instanceof StorageLocation ? $part_lot->getStorageLocation()->getFullPath() : '')
. ' (' . $part_lot->getName() . '): ' . $part_lot->getAmount()),
'query_builder' => fn(Options $options) => static fn(EntityRepository $er) => $er->createQueryBuilder('l')
->where('l.part = :part')

View file

@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Repository\Parts;
use App\Entity\Parts\Part;
use App\Entity\Parts\Storelocation;
use App\Entity\Parts\StorageLocation;
use App\Repository\AbstractPartsContainingRepository;
use Doctrine\ORM\QueryBuilder;
use InvalidArgumentException;
@ -37,7 +37,7 @@ class StorelocationRepository extends AbstractPartsContainingRepository
*/
public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
{
if (!$element instanceof Storelocation) {
if (!$element instanceof StorageLocation) {
throw new InvalidArgumentException('$element must be an Storelocation!');
}
@ -58,7 +58,7 @@ class StorelocationRepository extends AbstractPartsContainingRepository
public function getPartsCount(object $element): int
{
if (!$element instanceof Storelocation) {
if (!$element instanceof StorageLocation) {
throw new InvalidArgumentException('$element must be an Storelocation!');
}

View file

@ -0,0 +1,33 @@
<?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\Repository\UserSystem;
use App\Entity\UserSystem\ApiToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ApiTokenRepository extends EntityRepository
{
}

View file

@ -0,0 +1,52 @@
<?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\Security;
use App\Entity\UserSystem\ApiToken;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
class ApiTokenAuthenticatedToken extends PostAuthenticationToken
{
public function __construct(UserInterface $user, string $firewallName, array $roles, private readonly ApiToken $apiToken)
{
//Add roles for the API
$roles[] = 'ROLE_API_AUTHENTICATED';
//Add roles based on the token level
$roles = array_merge($roles, $apiToken->getLevel()->getAdditionalRoles());
parent::__construct($user, $firewallName, array_unique($roles));
}
/**
* Returns the API token that was used to authenticate the user.
* @return ApiToken
*/
public function getApiToken(): ApiToken
{
return $this->apiToken;
}
}

View file

@ -0,0 +1,156 @@
<?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\Security;
use App\Entity\UserSystem\ApiToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Authenticator similar to the builtin AccessTokenAuthenticator, but we return a Token here which contains information
* about the used token.
*/
class ApiTokenAuthenticator implements AuthenticatorInterface
{
public function __construct(
#[Autowire(service: 'security.access_token_extractor.header')]
private readonly AccessTokenExtractorInterface $accessTokenExtractor,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager,
private readonly string $realm = 'api',
) {
}
/**
* Gets the ApiToken belonging to the given accessToken string.
* If the token is invalid or expired, an exception is thrown and authentication fails.
* @param string $accessToken
* @return ApiToken
*/
private function getTokenFromString(#[\SensitiveParameter] string $accessToken): ApiToken
{
$repo = $this->entityManager->getRepository(ApiToken::class);
$token = $repo->findOneBy(['token' => $accessToken]);
if (!$token instanceof ApiToken) {
throw new BadCredentialsException();
}
if (!$token->isValid()) {
throw new CustomUserMessageAuthenticationException('Token expired');
}
$old_time = $token->getLastTimeUsed();
//Set the last used date of the token
$token->setLastTimeUsed(new \DateTimeImmutable());
//Only flush the token if the last used date change is more than 10 minutes
//For performance reasons we don't want to flush the token every time it is used, but only if it is used more than 10 minutes after the last time it was used
//If a flush is later in the code we don't want to flush the token again
if ($old_time === null || $old_time->diff($token->getLastTimeUsed())->i > 10) {
$this->entityManager->flush();
}
return $token;
}
public function supports(Request $request): ?bool
{
return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null;
}
public function authenticate(Request $request): Passport
{
$accessToken = $this->accessTokenExtractor->extractAccessToken($request);
if (!$accessToken) {
throw new BadCredentialsException('Invalid credentials.');
}
$apiToken = $this->getTokenFromString($accessToken);
$userBadge = new UserBadge($apiToken->getUser()?->getUserIdentifier() ?? throw new BadCredentialsException('Invalid credentials.'));
$apiBadge = new ApiTokenBadge($apiToken);
return new SelfValidatingPassport($userBadge, [$apiBadge]);
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
return new ApiTokenAuthenticatedToken(
$passport->getUser(),
$firewallName,
$passport->getUser()->getRoles(),
$passport->getBadge(ApiTokenBadge::class)?->getApiToken() ?? throw new \LogicException('Passport does not contain an API token.')
);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
$errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(),
'security');
return new Response(
null,
Response::HTTP_UNAUTHORIZED,
['WWW-Authenticate' => $this->getAuthenticateHeader($errorMessage)]
);
}
/**
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
*/
private function getAuthenticateHeader(string $errorDescription = null): string
{
$data = [
'realm' => $this->realm,
'error' => 'invalid_token',
'error_description' => $errorDescription,
];
$values = [];
foreach ($data as $k => $v) {
if (null === $v || '' === $v) {
continue;
}
$values[] = sprintf('%s="%s"', $k, $v);
}
return sprintf('Bearer %s', implode(',', $values));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
}

View file

@ -0,0 +1,51 @@
<?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\Security;
use App\Entity\UserSystem\ApiToken;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
class ApiTokenBadge implements BadgeInterface
{
/**
* @param ApiToken $apiToken
*/
public function __construct(private readonly ApiToken $apiToken)
{
}
/**
* @return ApiToken The token that was used to authenticate the user
*/
public function getApiToken(): ApiToken
{
return $this->apiToken;
}
public function isResolved(): bool
{
return true;
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
@ -34,32 +35,26 @@ use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\StorelocationAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
class AttachmentVoter extends ExtendedVoter
final class AttachmentVoter extends Voter
{
public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, protected Security $security)
private const ALLOWED_ATTRIBUTES = ['read', 'view', 'edit', 'delete', 'create', 'show_private', 'show_history'];
public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
parent::__construct($resolver, $entityManager);
}
/**
* Similar to voteOnAttribute, but checking for the anonymous user is already done.
* The current user (or the anonymous user) is passed by $user.
*
* @param string $attribute
*/
protected function voteOnUser(string $attribute, $subject, User $user): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
//This voter only works for attachments
if (!is_a($subject, Attachment::class, true)) {
@ -67,7 +62,7 @@ class AttachmentVoter extends ExtendedVoter
}
if ($attribute === 'show_private') {
return $this->resolver->inherit($user, 'attachments', 'show_private') ?? false;
return $this->helper->isGranted($token, 'attachments', 'show_private');
}
@ -99,7 +94,7 @@ class AttachmentVoter extends ExtendedVoter
$param = 'measurement_units';
} elseif (is_a($subject, PartAttachment::class, true)) {
$param = 'parts';
} elseif (is_a($subject, StorelocationAttachment::class, true)) {
} elseif (is_a($subject, StorageLocationAttachment::class, true)) {
$param = 'storelocations';
} elseif (is_a($subject, SupplierAttachment::class, true)) {
$param = 'suppliers';
@ -113,7 +108,7 @@ class AttachmentVoter extends ExtendedVoter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
}
return $this->resolver->inherit($user, $param, $this->mapOperation($attribute)) ?? false;
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
}
return false;
@ -141,10 +136,20 @@ class AttachmentVoter extends ExtendedVoter
{
if (is_a($subject, Attachment::class, true)) {
//These are the allowed attributes
return in_array($attribute, ['read', 'view', 'edit', 'delete', 'create', 'show_private', 'show_history'], true);
return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
//Allow class name as subject
return false;
}
public function supportsAttribute(string $attribute): bool
{
return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return $subjectType === 'string' || is_a($subjectType, Attachment::class, true);
}
}

View file

@ -0,0 +1,76 @@
<?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\Security\Voter;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class BOMEntryVoter extends Voter
{
private const ALLOWED_ATTRIBUTES = ['read', 'view', 'edit', 'delete', 'create'];
public function __construct(private readonly Security $security)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
if (!$subject instanceof ProjectBOMEntry) {
return false;
}
$project = $subject->getProject();
//Allow everything if the project was not set yet
if ($project === null) {
return true;
}
//Entry can be read if the user has read access to the project
if ($attribute === 'read') {
return $this->security->isGranted('read', $project);
}
//Everything else can be done if the user has edit access to the project
return $this->security->isGranted('edit', $project);
}
public function supportsAttribute(string $attribute): bool
{
return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
public function supportsType(string $subjectType): bool
{
return is_a($subjectType, ProjectBOMEntry::class, true);
}
}

View file

@ -1,68 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Repository\UserRepository;
use App\Services\UserSystem\PermissionManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* The purpose of this class is, to use the anonymous user from DB in the case, that nobody is logged in.
*/
abstract class ExtendedVoter extends Voter
{
public function __construct(protected PermissionManager $resolver, protected EntityManagerInterface $entityManager)
{
}
final protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
//An allowed user is not allowed to do anything...
if ($user instanceof User && $user->isDisabled()) {
return false;
}
// if the user is anonymous (meaning $user is null), we use the anonymous user.
if (!$user instanceof User) {
/** @var UserRepository $repo */
$repo = $this->entityManager->getRepository(User::class);
$user = $repo->getAnonymousUser();
if (!$user instanceof User) {
return false;
}
}
return $this->voteOnUser($attribute, $subject, $user);
}
/**
* Similar to voteOnAttribute, but checking for the anonymous user is already done.
* The current user (or the anonymous user) is passed by $user.
*/
abstract protected function voteOnUser(string $attribute, $subject, User $user): bool;
}

View file

@ -24,18 +24,26 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class GroupVoter extends ExtendedVoter
final class GroupVoter extends Voter
{
public function __construct(private readonly VoterHelper $helper)
{
}
/**
* Similar to voteOnAttribute, but checking for the anonymous user is already done.
* The current user (or the anonymous user) is passed by $user.
*
* @param string $attribute
*/
protected function voteOnUser(string $attribute, $subject, User $user): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
return $this->resolver->inherit($user, 'groups', $attribute) ?? false;
return $this->helper->isGranted($token, 'groups', $attribute);
}
/**
@ -49,9 +57,19 @@ class GroupVoter extends ExtendedVoter
protected function supports(string $attribute, $subject): bool
{
if (is_a($subject, Group::class, true)) {
return $this->resolver->isValidOperation('groups', $attribute);
return $this->helper->isValidOperation('groups', $attribute);
}
return false;
}
public function supportsAttribute(string $attribute): bool
{
return $this->helper->isValidOperation('groups', $attribute);
}
public function supportsType(string $subjectType): bool
{
return $subjectType === 'string' || is_a($subjectType, Group::class, true);
}
}

View file

@ -24,22 +24,36 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* This voter implements a virtual role, which can be used if the user has any permission set to allowed.
* We use this to restrict access to the homepage.
*/
class HasAccessPermissionsVoter extends ExtendedVoter
final class HasAccessPermissionsVoter extends Voter
{
public const ROLE = "HAS_ACCESS_PERMISSIONS";
protected function voteOnUser(string $attribute, $subject, User $user): bool
public function __construct(private readonly PermissionManager $permissionManager, private readonly VoterHelper $helper)
{
return $this->resolver->hasAnyPermissionSetToAllowInherited($user);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $this->helper->resolveUser($token);
return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::ROLE;
}
public function supportsAttribute(string $attribute): bool
{
return $attribute === self::ROLE;
}
}

View file

@ -25,36 +25,36 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class ImpersonateUserVoter extends Voter
final class ImpersonateUserVoter extends Voter
{
public function __construct(private PermissionManager $permissionManager)
public function __construct(private readonly VoterHelper $helper)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute == 'CAN_SWITCH_USER'
return $attribute === 'CAN_SWITCH_USER'
&& $subject instanceof UserInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return $this->helper->isGranted($token, 'users', 'impersonate');
}
if (!$user instanceof User || !$subject instanceof UserInterface) {
return false;
}
public function supportsAttribute(string $attribute): bool
{
return $attribute === 'CAN_SWITCH_USER';
}
//An disabled user is not allowed to do anything...
if ($user->isDisabled()) {
return false;
}
return $this->permissionManager->inherit($user, 'users', 'impersonate') ?? false;
public function supportsType(string $subjectType): bool
{
return is_a($subjectType, User::class, true);
}
}

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