From edaa294cc93d803e04e13600892fb19a35a8c8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 6 Jan 2025 12:22:39 +0100 Subject: [PATCH 01/12] fix: match hotel codes assigned to users at beginning or end --- .../DataProvider/HotelDataProvider.php | 2 +- .../HouseManager/IndexController.php | 12 ++-------- src/Security/Voter/DispositionVoter.php | 24 +++++++++---------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/BusProNet/DataProvider/HotelDataProvider.php b/src/BusProNet/DataProvider/HotelDataProvider.php index 39e6ee6..49409e4 100644 --- a/src/BusProNet/DataProvider/HotelDataProvider.php +++ b/src/BusProNet/DataProvider/HotelDataProvider.php @@ -43,7 +43,7 @@ class HotelDataProvider $hotels = []; foreach ($this->getAll() as $hotel) { - if (str_starts_with($hotel->getCode(), $code)) { + if (str_starts_with($hotel->getCode(), $code) || str_ends_with($hotel->getCode(), $code)) { $hotels[] = $hotel; } } diff --git a/src/Controller/HouseManager/IndexController.php b/src/Controller/HouseManager/IndexController.php index 8bb6733..8b21b2b 100644 --- a/src/Controller/HouseManager/IndexController.php +++ b/src/Controller/HouseManager/IndexController.php @@ -2,7 +2,6 @@ namespace App\Controller\HouseManager; -use App\BusProNet\DataProvider\HotelDataProvider; use App\Entity\User; use App\Repository\DispositionRepository; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -12,10 +11,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class IndexController extends AbstractController { - public function __construct( - private readonly HotelDataProvider $hotelDataProvider, - private readonly DispositionRepository $dispositionRepository - ) { + public function __construct(private readonly DispositionRepository $dispositionRepository) + { } #[Route('/house-manager', name: 'app_house_manager_index')] @@ -25,11 +22,6 @@ class IndexController extends AbstractController /** @var User $user */ $user = $this->getUser(); - $hotels = $this - ->hotelDataProvider - ->findByCode($user->getHotelCode()) - ; - $newDispositions = $this ->dispositionRepository ->findNewDispositionsByHotelCode($user->getHotelCode()) diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index bf269f8..3f2987d 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -69,20 +69,18 @@ class DispositionVoter extends Voter return false; } - $hotels = $this - ->hotelDataProvider - ->findByCode($token->getUser()->getHotelCode()) - ; - $hotelBusProIds = array_map(function (Hotel $hotel) { - return $hotel->getBusProId(); - }, $hotels); - $destination = $disposition - ->getAssignment() - ->getDestination() - ; + $userHotelCode = $token->getUser()->getHotelCode(); - return in_array($destination->getHotelBusProId(), $hotelBusProIds) - && $destination->getDateTo() < new \DateTimeImmutable(); + $destination = $disposition + ->getAssignment() + ->getDestination() + ; + + $isMatchingHotel = str_starts_with($destination->getHotelCode(), $userHotelCode) + || str_ends_with($destination->getHotelCode(), $userHotelCode); + $isPast = $destination->getDateTo() < new \DateTimeImmutable(); + + return $isMatchingHotel && $isPast; } private function assertTeamerAccess(Disposition $disposition): bool From c15188c43cb46bb94dedc0e2927ece2e2368567a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 10:07:19 +0100 Subject: [PATCH 02/12] feat: multiple hotelcodes assignable to users --- composer.json | 1 + composer.lock | 74 ++++++++++++++++++- config/packages/doctrine.yaml | 1 + migrations/Version20250107163840.php | 46 ++++++++++++ src/BusProNet/Model/CrmAttributesResponse.php | 10 +-- src/BusProNet/ResponseParser.php | 8 +- src/BusProNet/UserDataHandler.php | 8 +- .../HouseManager/Feedback/IndexController.php | 2 +- .../HouseManager/IndexController.php | 4 +- src/Entity/User.php | 25 ++++++- src/Repository/DispositionRepository.php | 24 ++++-- src/Repository/UserRepository.php | 53 ++++++------- src/Security/BpnAuthenticator.php | 4 +- src/Security/Voter/DispositionVoter.php | 3 +- src/Service/Cron/FeedbackReminderService.php | 34 ++++++--- tests/Entity/UserTest.php | 26 +++++++ 16 files changed, 256 insertions(+), 67 deletions(-) create mode 100644 migrations/Version20250107163840.php create mode 100644 tests/Entity/UserTest.php diff --git a/composer.json b/composer.json index e51052c..5e5a0bb 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "oneup/uploader-bundle": "^4.0", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.22", + "scienta/doctrine-json-functions": "^6.3", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.5", "spatie/icalendar-generator": "^2.5", diff --git a/composer.lock b/composer.lock index eb03003..2349c79 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1e48d4a4ffe643c14cf370b7083adddb", + "content-hash": "804c55640363108581ce07cf9dedaece", "packages": [ { "name": "beberlei/doctrineextensions", @@ -2979,6 +2979,78 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "scienta/doctrine-json-functions", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/ScientaNL/DoctrineJsonFunctions.git", + "reference": "554b2fd281e976a791501fc4753ffd4c5891ec62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ScientaNL/DoctrineJsonFunctions/zipball/554b2fd281e976a791501fc4753ffd4c5891ec62", + "reference": "554b2fd281e976a791501fc4753ffd4c5891ec62", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.2 || ^4", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/orm": "^2.19 || ^3", + "ext-pdo": "*", + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-doctrine": "^1.4", + "phpstan/phpstan-phpunit": "^1.4", + "phpunit/phpunit": "^10.1", + "psalm/plugin-phpunit": "^0.18", + "slevomat/coding-standard": "~8", + "symfony/cache": "^5.4 || ^6.4 || ^7", + "vimeo/psalm": "^5.2", + "webmozart/assert": "^1.11" + }, + "suggest": { + "dunglas/doctrine-json-odm": "To serialize / deserialize objects as JSON documents." + }, + "type": "library", + "autoload": { + "psr-4": { + "Scienta\\DoctrineJsonFunctions\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Doctrine Json Functions Contributors", + "homepage": "https://github.com/ScientaNL/DoctrineJsonFunctions/contributors" + } + ], + "description": "A set of extensions to Doctrine that add support for json query functions.", + "keywords": [ + "database", + "doctrine", + "dql", + "json", + "mariadb", + "mysql", + "orm", + "postgres", + "postgresql", + "sqlite" + ], + "support": { + "issues": "https://github.com/ScientaNL/DoctrineJsonFunctions/issues", + "source": "https://github.com/ScientaNL/DoctrineJsonFunctions/tree/6.3.0" + }, + "time": "2024-11-08T12:33:19+00:00" + }, { "name": "setasign/fpdf", "version": "1.8.6", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 45975e9..670a00c 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -23,6 +23,7 @@ doctrine: string_functions: DATE_FORMAT: DoctrineExtensions\Query\Mysql\DateFormat FIELD: DoctrineExtensions\Query\Mysql\Field + JSON_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Mysql\JsonContains datetime_functions: DATEDIFF: DoctrineExtensions\Query\Mysql\DateDiff when@test: diff --git a/migrations/Version20250107163840.php b/migrations/Version20250107163840.php new file mode 100644 index 0000000..c217f82 --- /dev/null +++ b/migrations/Version20250107163840.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE user ADD hotel_codes JSON NOT NULL COMMENT \'(DC2Type:json)\''); + } + + public function postUp(Schema $schema): void + { + $this->connection->executeQuery('UPDATE user SET hotel_codes="[]" WHERE hotel_code IS NULL'); + + $rows = $this->connection->fetchAllAssociative('SELECT id, hotel_code FROM user WHERE hotel_code IS NOT NULL'); + + foreach ($rows as $row) { + $this->connection->update('user', [ + 'hotel_codes' => json_encode([$row['hotel_code']]), + ], [ + 'id' => $row['id'], + ]); + } + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE user DROP hotel_codes'); + } +} diff --git a/src/BusProNet/Model/CrmAttributesResponse.php b/src/BusProNet/Model/CrmAttributesResponse.php index 299bb30..15e526c 100644 --- a/src/BusProNet/Model/CrmAttributesResponse.php +++ b/src/BusProNet/Model/CrmAttributesResponse.php @@ -9,7 +9,7 @@ class CrmAttributesResponse private bool $manager = false; private bool $houseManager = false; private bool $teamer = false; - private ?string $hotelCode = null; + private array $hotelCodes = []; public function getAttributeGroups(): ?array { @@ -70,14 +70,14 @@ class CrmAttributesResponse return $this; } - public function getHotelCode(): ?string + public function getHotelCodes(): array { - return $this->hotelCode; + return $this->hotelCodes; } - public function setHotelCode(?string $hotelCode): static + public function setHotelCodes(array $hotelCodes): static { - $this->hotelCode = $hotelCode; + $this->hotelCodes = $hotelCodes; return $this; } diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php index 073c6e7..54b6840 100644 --- a/src/BusProNet/ResponseParser.php +++ b/src/BusProNet/ResponseParser.php @@ -142,7 +142,7 @@ class ResponseParser { $groups = []; $isAdmin = $isManager = $isHouseManager = $isTeamer = false; - $hotelCode = null; + $hotelCodes = []; foreach ($xml->selektionsmerkmale->selektionsgruppe as $item) { $group = new CrmAttributeGroup(); @@ -164,7 +164,7 @@ class ResponseParser if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attributeLabel, $matches) && true === $attribute->isSelected()) { $isHouseManager = true; - $hotelCode = $matches[1]; + $hotelCodes[] = $matches[1]; } if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) { $isAdmin = true; @@ -183,7 +183,7 @@ class ResponseParser // Apply additional role and hotel code for testing purposes when provided if ($isAdmin && null !== $this->config['bpn_default_hotel_code']) { $isHouseManager = true; - $hotelCode = $this->config['bpn_default_hotel_code']; + $hotelCodes[] = $this->config['bpn_default_hotel_code']; } $response = new CrmAttributesResponse(); @@ -193,7 +193,7 @@ class ResponseParser ->setManager($isManager) ->setTeamer($isTeamer) ->setHouseManager($isHouseManager) - ->setHotelCode($hotelCode) + ->setHotelCodes($hotelCodes) ; return $response; diff --git a/src/BusProNet/UserDataHandler.php b/src/BusProNet/UserDataHandler.php index f09b639..8148b43 100644 --- a/src/BusProNet/UserDataHandler.php +++ b/src/BusProNet/UserDataHandler.php @@ -55,7 +55,7 @@ class UserDataHandler array $roles, bool $isTeamer = false, array $crmSelections = [], - ?string $hotelCode = null + array $hotelCodes = [] ): User { $user = new User(); $user @@ -64,7 +64,7 @@ class UserDataHandler ->setEmail($profileResponse->getCommunication()->getEmail()) ->setBusProPersonId($profileResponse->getPersonId()) ->setBusProAddressId($profileResponse->getAddressId()) - ->setHotelCode($hotelCode) + ->setHotelCodes($hotelCodes) ->setRoles($roles) ; @@ -95,12 +95,12 @@ class UserDataHandler array $roles, bool $isTeamer = false, array $crmSelections = [], - ?string $hotelCode = null + array $hotelCodes = [] ): void { $user ->setEmail($profileResponse->getCommunication()->getEmail()) ->setRoles($roles) - ->setHotelCode($hotelCode) + ->setHotelCodes($hotelCodes) ; if (true === $isTeamer) { diff --git a/src/Controller/HouseManager/Feedback/IndexController.php b/src/Controller/HouseManager/Feedback/IndexController.php index b4169ac..d1b0d79 100644 --- a/src/Controller/HouseManager/Feedback/IndexController.php +++ b/src/Controller/HouseManager/Feedback/IndexController.php @@ -28,7 +28,7 @@ class IndexController extends AbstractController $query = $this ->dispositionRepository - ->getDispositionsWithPendingFeedbackQuery($user->getHotelCode()) + ->getDispositionsWithPendingFeedbackQuery($user->getHotelCodes()) ; $pagination = $this->paginator->paginate( diff --git a/src/Controller/HouseManager/IndexController.php b/src/Controller/HouseManager/IndexController.php index 8bb6733..30a9024 100644 --- a/src/Controller/HouseManager/IndexController.php +++ b/src/Controller/HouseManager/IndexController.php @@ -32,12 +32,12 @@ class IndexController extends AbstractController $newDispositions = $this ->dispositionRepository - ->findNewDispositionsByHotelCode($user->getHotelCode()) + ->findNewDispositionsByHotelCodes($user->getHotelCodes()) ; $pendingFeedbacks = $this ->dispositionRepository - ->findDispositionsWithPendingFeedbackByHotelCode($user->getHotelCode()) + ->findDispositionsWithPendingFeedbackByHotelCodes($user->getHotelCodes()) ; return $this->render('house_manager/index.html.twig', [ diff --git a/src/Entity/User.php b/src/Entity/User.php index 6214a04..a9c0295 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -54,6 +54,9 @@ class User implements UserInterface, TimestampableEntityInterface #[ORM\Column(length: 32, nullable: true)] private ?string $hotelCode = null; + #[ORM\Column(type: 'json')] + private array $hotelCodes = []; + #[ORM\Column] private bool $muteNotifications = false; @@ -256,14 +259,14 @@ class User implements UserInterface, TimestampableEntityInterface return $this; } - public function getHotelCode(): ?string + public function getHotelCodes(): array { - return $this->hotelCode; + return $this->hotelCodes; } - public function setHotelCode(?string $hotelCode): static + public function setHotelCodes(array $hotelCodes): static { - $this->hotelCode = $hotelCode; + $this->hotelCodes = $hotelCodes; return $this; } @@ -279,4 +282,18 @@ class User implements UserInterface, TimestampableEntityInterface return $this; } + + public function hasHotelCodeMatch(string $hotelCode): bool + { + foreach ($this->hotelCodes as $userHotelCode) { + if ( + str_starts_with($hotelCode, $userHotelCode) + || str_ends_with($hotelCode, $userHotelCode) + ) { + return true; + } + } + + return false; + } } diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index 98e0ee2..ffa765b 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -148,8 +148,10 @@ class DispositionRepository extends ServiceEntityRepository ; } - public function findNewDispositionsByHotelCode(string $hotelCode): array + public function findNewDispositionsByHotelCodes(mixed $hotelCode): array { + $hotelCodes = (array) $hotelCode; + $qb = $this->createQueryBuilder('disposition'); return $qb @@ -157,12 +159,15 @@ class DispositionRepository extends ServiceEntityRepository ->innerJoin('disposition.assignment', 'assignment') ->innerJoin('assignment.destination', 'destination') ->where($qb->expr()->andX( - $qb->expr()->like('destination.hotelCode', ':hotelCode'), + $qb->expr()->orX( + $qb->expr()->in($qb->expr()->substring('destination.hotelCode', 1, 3), ':hotelCodes'), + $qb->expr()->in($qb->expr()->substring('destination.hotelCode', -3, 3), ':hotelCodes'), + ), $qb->expr()->gte('destination.dateTo', ':dateTo'), $qb->expr()->eq('disposition.status', ':status') )) ->orderBy('destination.dateFrom', 'ASC') - ->setParameter('hotelCode', '%'.$hotelCode.'%') + ->setParameter('hotelCodes', $hotelCodes) ->setParameter('dateTo', new \DateTimeImmutable()) ->setParameter('status', Disposition::STATUS_CONFIRMED) ->getQuery() @@ -170,8 +175,10 @@ class DispositionRepository extends ServiceEntityRepository ; } - public function getDispositionsWithPendingFeedbackQuery(?string $hotelCode = null, ?int $offsetDays = null): Query + public function getDispositionsWithPendingFeedbackQuery(mixed $hotelCode = null, ?int $offsetDays = null): Query { + $hotelCodes = (array) $hotelCode; + $qb = $this->createQueryBuilder('disposition'); $qb @@ -201,8 +208,11 @@ class DispositionRepository extends ServiceEntityRepository if (null !== $hotelCode) { $qb - ->andWhere($qb->expr()->like('destination.hotelCode', ':hotelCode')) - ->setParameter('hotelCode', '%'.$hotelCode.'%') + ->andWhere($qb->expr()->orX( + $qb->expr()->in($qb->expr()->substring('destination.hotelCode', 1, 3), ':hotelCodes'), + $qb->expr()->in($qb->expr()->substring('destination.hotelCode', -3, 3), ':hotelCodes'), + )) + ->setParameter('hotelCodes', $hotelCodes) ; } @@ -217,7 +227,7 @@ class DispositionRepository extends ServiceEntityRepository ; } - public function findDispositionsWithPendingFeedbackByHotelCode(string $hotelCode): array + public function findDispositionsWithPendingFeedbackByHotelCodes(mixed $hotelCode): array { return $this ->getDispositionsWithPendingFeedbackQuery($hotelCode) diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 479e310..b925f8b 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -32,16 +32,14 @@ class UserRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('u'); return $qb - ->where($qb->expr()->orX( - $qb->expr()->like('u.roles', ':role_admin'), - $qb->expr()->like('u.roles', ':role_manager'), - $qb->expr()->like('u.roles', ':role_house_manager') - )) + ->where("JSON_CONTAINS(u.roles, :role_admin) = 1") + ->orWhere("JSON_CONTAINS(u.roles, :role_manager) = 1") + ->orWhere("JSON_CONTAINS(u.roles, :role_house_manager) = 1") ->orderBy('u.lastName', 'ASC') ->setParameters([ - 'role_admin' => '%"ROLE_ADMIN"%', - 'role_manager' => '%"ROLE_MANAGER"%', - 'role_house_manager' => '%"ROLE_HOUSE_MANAGER"%', + 'role_admin' => json_encode('ROLE_ADMIN'), + 'role_manager' => json_encode('ROLE_MANAGER'), + 'role_house_manager' => json_encode('ROLE_HOUSE_MANAGER'), ]) ->getQuery() ->getResult() @@ -50,22 +48,29 @@ class UserRepository extends ServiceEntityRepository /** * @param string $role - * @param string $hotelCode + * @param string[]|string $hotelCode * @return User[] */ - public function getUsersByRoleAndHotelCode(string $role, string $hotelCode): array + public function getUsersByRoleAndHotelCode(string $role, mixed $hotelCode): array { - $hotelCodeBase = substr($hotelCode, 0, 3); + $hotelCodes = (array) $hotelCode; + + $hotelCodesBase = array_map(function ($code) { + return substr($code, 0, 3); + }, $hotelCodes); $qb = $this->createQueryBuilder('user'); + foreach ($hotelCodesBase as $index => $code) { + $qb + ->orWhere('JSON_CONTAINS(user.hotelCodes, :hotelCode_'.$index.') = 1') + ->setParameter('hotelCode_'.$index, json_encode($code)) + ; + } + return $qb - ->where($qb->expr()->andX( - $qb->expr()->eq('user.hotelCode', ':hotelCode'), - $qb->expr()->like('user.roles', ':role') - )) - ->setParameter('hotelCode', $hotelCodeBase) - ->setParameter('role', '%"'.$role.'"%') + ->andWhere('JSON_CONTAINS(user.roles, :role) = 1') + ->setParameter('role', json_encode($role)) ->getQuery() ->getResult() ; @@ -76,17 +81,15 @@ class UserRepository extends ServiceEntityRepository $qb = $this->createQueryBuilder('user'); $users = $qb - ->where($qb->expr()->andX( - $qb->expr()->orX( - $qb->expr()->like('user.lastName', ':search'), - $qb->expr()->like('user.firstName', ':search'), - )), - $qb->expr()->like('user.roles', ':role') - ) + ->where($qb->expr()->orX( + $qb->expr()->like('user.lastName', ':search'), + $qb->expr()->like('user.firstName', ':search'), + )) + ->andWhere('JSON_CONTAINS(user.roles, :role) = 1') ->orderBy('user.lastName', 'ASC') ->addOrderBy('user.firstName', 'ASC') ->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%') - ->setParameter('role', '%"'.$this->escapeLikeWildcards($role).'"%') + ->setParameter('role', json_encode($role)) ->getQuery() ->getResult() ; diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 21f2a0e..a741e27 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -137,7 +137,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent if (null !== $user) { $this ->userDataHandler - ->updateLocalUser($user, $profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCode()) + ->updateLocalUser($user, $profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes()) ; return $user; @@ -145,7 +145,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return $this ->userDataHandler - ->createLocalUser($profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCode()) + ->createLocalUser($profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes()) ; } } diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index bf269f8..f304601 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -5,6 +5,7 @@ namespace App\Security\Voter; use App\BusProNet\DataProvider\HotelDataProvider; use App\BusProNet\Model\Hotel; use App\Entity\Disposition; +use App\Entity\User; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -93,4 +94,4 @@ class DispositionVoter extends Voter return $this->security->getUser()->getTeamer() === $disposition->getTeamer(); } -} \ No newline at end of file +} diff --git a/src/Service/Cron/FeedbackReminderService.php b/src/Service/Cron/FeedbackReminderService.php index cfa815f..e0ddd26 100644 --- a/src/Service/Cron/FeedbackReminderService.php +++ b/src/Service/Cron/FeedbackReminderService.php @@ -38,24 +38,30 @@ class FeedbackReminderService continue; } - // Get hotel base code to find hotel manager later - $hotelBaseCode = substr($hotel->getCode(), 0, 3); + // Get hotel code to find hotel manager later + $hotelCode = $hotel->getCode(); - if (false === array_key_exists($hotelBaseCode, $sortedFeedbacks)) { - $sortedFeedbacks[$hotelBaseCode] = []; + if (false === array_key_exists($hotelCode, $sortedFeedbacks)) { + $sortedFeedbacks[$hotelCode] = []; } // Sort feedback by hotel base code - $sortedFeedbacks[$hotelBaseCode][] = $disposition; + $sortedFeedbacks[$hotelCode][] = $disposition; } - // Determine hotel managers to notify - $hotelBaseCodes = array_keys($sortedFeedbacks); + // find hotel managers by hotel codes starting or ending with base code + $hotelCodes = array_keys($sortedFeedbacks); + $hotelBaseCodes = []; + foreach ($hotelCodes as $hotelCode) { + $hotelBaseCodes[] = substr($hotelCode, 0, 3); + $hotelBaseCodes[] = substr($hotelCode, -3, 3); + } /** @var User[] $hotelManagers */ - $hotelManagers = $this->userRepository->findBy([ - 'hotelCode' => $hotelBaseCodes, - ]); + $hotelManagers = $this + ->userRepository + ->getUsersByRoleAndHotelCode('ROLE_HOTEL_MANAGER', $hotelBaseCodes) + ; if (0 === count($hotelManagers)) { return 'No feedback reminders to be sent to hotel managers'; @@ -65,8 +71,14 @@ class FeedbackReminderService if (true === $manager->isMuteNotifications()) { continue; } + $feedbackKeys = array_filter(array_keys($sortedFeedbacks), function (string $key) use ($manager) { + return $manager->hasHotelCodeMatch($key); + }); + $feedbacksForManager = array_filter($sortedFeedbacks, function ($key) use ($feedbackKeys) { + return in_array($key, $feedbackKeys); + }, ARRAY_FILTER_USE_KEY); $this->mailer->createAndSendEmail([ - 'feedbacks' => $sortedFeedbacks[$manager->getHotelCode()], + 'feedbacks' => $feedbacksForManager, ], [ 'to' => $manager->getEmail(), 'subject' => 'Reminder: Offene Feedbacks', diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php new file mode 100644 index 0000000..50f9096 --- /dev/null +++ b/tests/Entity/UserTest.php @@ -0,0 +1,26 @@ +setHotelCodes(['ABC', 'DEF', 'GHI']); + + $isMatch = $user->hasHotelCodeMatch('ABCXXX'); + $this->assertTrue($isMatch); + + $isMatch = $user->hasHotelCodeMatch('XXXGHI'); + $this->assertTrue($isMatch); + + $isMatch = $user->hasHotelCodeMatch('XXDEFXX'); + $this->assertFalse($isMatch); + } +} From e3a8c8df68197165661adba05b19849996cce3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 10:23:07 +0100 Subject: [PATCH 03/12] chore: add deployer config for staging environment --- deploy.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/deploy.php b/deploy.php index 57a47a0..3fd3725 100644 --- a/deploy.php +++ b/deploy.php @@ -80,6 +80,20 @@ host('prod') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep-team.ep-reisen.de') ; +host('staging') + ->setHostname('185.243.132.170') + ->setRemoteUser('p546473') + ->setForwardAgent(true) + ->setSshMultiplexing(true) + ->setDeployPath('/home/www/p546473/html/myep-team') + ->set('writable_mode', 'chmod') + ->set('bin/php', '/usr/local/bin/php') + ->set('rsync_src', __DIR__) + ->set('rsync', $rsyncOptions) + ->set('keep_releases', 5) + ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep-team.dreipunktnull.net') +; + task('deploy', [ 'deploy:info', 'deploy:setup', From 5a463938d5ec8b2f1666d66fdd4c39f9e5f1fbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 10:43:03 +0100 Subject: [PATCH 04/12] fix: adopt multiple hotel codes --- src/Security/Voter/DispositionVoter.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 45ed09f..e1a9c7b 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -2,8 +2,6 @@ namespace App\Security\Voter; -use App\BusProNet\DataProvider\HotelDataProvider; -use App\BusProNet\Model\Hotel; use App\Entity\Disposition; use App\Entity\User; use Symfony\Bundle\SecurityBundle\Security; @@ -19,10 +17,8 @@ class DispositionVoter extends Voter public const INVOICE = 'INVOICE'; public const FEEDBACK = 'FEEDBACK'; - public function __construct( - private readonly Security $security, - private readonly HotelDataProvider $hotelDataProvider - ) { + public function __construct(private readonly Security $security) + { } protected function supports(string $attribute, mixed $subject): bool @@ -70,15 +66,15 @@ class DispositionVoter extends Voter return false; } - $userHotelCode = $token->getUser()->getHotelCode(); + /** @var User $user */ + $user = $token->getUser(); $destination = $disposition ->getAssignment() ->getDestination() ; - $isMatchingHotel = str_starts_with($destination->getHotelCode(), $userHotelCode) - || str_ends_with($destination->getHotelCode(), $userHotelCode); + $isMatchingHotel = $user->hasHotelCodeMatch($destination->getHotelCode()); $isPast = $destination->getDateTo() < new \DateTimeImmutable(); return $isMatchingHotel && $isPast; From f269068b71be8ef12b0ba6865fee53b7b6877206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 13:04:49 +0100 Subject: [PATCH 05/12] feat: upload in behalf of teamer for administrative users --- .../Disposition/DocumentUploadController.php | 107 ++++++++++++++++++ .../DispositionWorkflowGuardSubscriber.php | 13 ++- .../assignment/detail.html.twig | 9 ++ .../modal_document_upload.html.twig | 20 ++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/Controller/Administrative/Disposition/DocumentUploadController.php create mode 100644 templates/administrative/disposition/modal_document_upload.html.twig diff --git a/src/Controller/Administrative/Disposition/DocumentUploadController.php b/src/Controller/Administrative/Disposition/DocumentUploadController.php new file mode 100644 index 0000000..2a49821 --- /dev/null +++ b/src/Controller/Administrative/Disposition/DocumentUploadController.php @@ -0,0 +1,107 @@ +workflow->can($disposition, 'upload_contract')) { + $uploadType = Upload::TYPE_CONTRACT; + $uploadTypeLabel = 'Honorarvertrag'; + $uploaderRoute = '_uploader_upload_contract'; + } elseif (true === $this->workflow->can($disposition, 'upload_invoice')) { + $uploadType = Upload::TYPE_INVOICE; + $uploadTypeLabel = 'Honoranote'; + $uploaderRoute = '_uploader_upload_invoice'; + } else { + $this->addFlash('error', 'Kein Upload möglich beim aktuellen Status der Einteilung'); + + return $this->getRedirectResponse($disposition); + } + + $teamer = $disposition->getTeamer(); + $modalTitle = sprintf('%s für %s hochladen', $uploadTypeLabel, $teamer); + + $form = $this->createFormBuilder()->getForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->processUpload($disposition, $uploadType); + + $this->addFlash('success', sprintf('%s wurde hochgeladen', $uploadTypeLabel)); + + return $this->getRedirectResponse($disposition); + } + + return $this->render('administrative/disposition/modal_document_upload.html.twig', [ + 'disposition' => $disposition, + 'modalTitle' => $modalTitle, + 'uploaderRoute' => $uploaderRoute, + 'form' => $form->createView(), + ]); + } + + private function processUpload(Disposition $disposition, string $uploadType): void + { + if (null !== $existingUpload = $disposition->getDocumentByType($uploadType)) { + $this->entityManager->remove($existingUpload); + } + + $teamer = $disposition->getTeamer(); + $user = $teamer->getUser(); + + $uploadSession = $this->uploadHandler->getUploadSession(); + + $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, $uploadType); + $upload->setStatus(Upload::STATUS_NEW); + + $disposition->addDocument($upload); + + $transitionName = sprintf('upload_%s', $uploadType); + $this->workflow->apply($disposition, $transitionName); + + $this->entityManager->flush(); + + $this->uploadHandler->moveUploadSessionFilesFromOrphanage($uploadType, $uploadSession); + $this->uploadHandler->destroyUploadSession(); + + $this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME); + } + + private function getRedirectResponse(Disposition $disposition): HxRedirectResponse + { + return new HxRedirectResponse($this->generateUrl('app_administrative_assignment_detail', [ + 'uuid' => $disposition->getAssignment()->getUuid(), + ])); + } +} diff --git a/src/EventListener/DispositionWorkflowGuardSubscriber.php b/src/EventListener/DispositionWorkflowGuardSubscriber.php index 65fe97d..fbc5859 100644 --- a/src/EventListener/DispositionWorkflowGuardSubscriber.php +++ b/src/EventListener/DispositionWorkflowGuardSubscriber.php @@ -4,13 +4,14 @@ namespace App\EventListener; use App\Entity\Disposition; use App\Entity\Upload; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Workflow\Event\GuardEvent; use Symfony\Contracts\Translation\TranslatorInterface; class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface { - public function __construct(private readonly TranslatorInterface $translator) + public function __construct(private readonly TranslatorInterface $translator, private readonly Security $security) {} public static function getSubscribedEvents(): array @@ -40,6 +41,11 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface public function guardUploadContract(GuardEvent $event): void { + // no checks for administrative users + if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) { + return; + } + /** @var Disposition $disposition */ $disposition = $event->getSubject(); $assignment = $disposition->getAssignment(); @@ -79,6 +85,11 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface $event->setBlocked(true, $message); } + // no further checks for administrative users + if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) { + return; + } + $earliestDate = $destination->getDateTo(); if ($earliestDate > $today) { diff --git a/templates/administrative/assignment/detail.html.twig b/templates/administrative/assignment/detail.html.twig index b95deb4..aece30e 100644 --- a/templates/administrative/assignment/detail.html.twig +++ b/templates/administrative/assignment/detail.html.twig @@ -179,6 +179,15 @@
+ {% if workflow_can(disposition, 'upload_contract') or workflow_can(disposition, 'upload_invoice') %} + + {% endif %} {% if is_granted('DELETE', disposition) %}
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} \ No newline at end of file From dad6965494ca840132d90bda286e086701dc3f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 13:10:14 +0100 Subject: [PATCH 06/12] feat: log uploads by administrative users on behalf of teamers --- .../Disposition/DocumentUploadController.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Controller/Administrative/Disposition/DocumentUploadController.php b/src/Controller/Administrative/Disposition/DocumentUploadController.php index 2a49821..e090c1a 100644 --- a/src/Controller/Administrative/Disposition/DocumentUploadController.php +++ b/src/Controller/Administrative/Disposition/DocumentUploadController.php @@ -8,6 +8,7 @@ use App\Event\DocumentUploadedEvent; use App\Htmx\HxRedirectResponse; use App\Service\Upload\UploadHandler; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\HttpFoundation\Request; @@ -25,6 +26,7 @@ class DocumentUploadController extends AbstractController #[Target('disposition')] private readonly WorkflowInterface $workflow, private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface $logger, ) { } @@ -96,6 +98,14 @@ class DocumentUploadController extends AbstractController $this->uploadHandler->destroyUploadSession(); $this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME); + + $this->logger->info('Document upload for teamer', [ + 'teamer_id' => $teamer->getId(), + 'teamer_name' => $teamer->getFullName(), + 'disposition_id' => $disposition->getId(), + 'document_id' => $upload->getId(), + 'upload_type' => $uploadType, + ]); } private function getRedirectResponse(Disposition $disposition): HxRedirectResponse From 3d20d5cf9435fb89b9c9d026f25cc8c8e1803d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 8 Jan 2025 18:00:25 +0100 Subject: [PATCH 07/12] feat: refactor teamer-document upload for admins --- .../Disposition/DocumentUploadController.php | 53 +++++-------------- .../DispositionWorkflowGuardSubscriber.php | 13 +---- src/Security/Voter/DispositionVoter.php | 20 +++++-- .../assignment/detail.html.twig | 2 +- .../modal_document_upload.html.twig | 2 +- 5 files changed, 32 insertions(+), 58 deletions(-) diff --git a/src/Controller/Administrative/Disposition/DocumentUploadController.php b/src/Controller/Administrative/Disposition/DocumentUploadController.php index e090c1a..ee4ed11 100644 --- a/src/Controller/Administrative/Disposition/DocumentUploadController.php +++ b/src/Controller/Administrative/Disposition/DocumentUploadController.php @@ -10,12 +10,10 @@ use App\Service\Upload\UploadHandler; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; -use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class DocumentUploadController extends AbstractController @@ -23,8 +21,6 @@ class DocumentUploadController extends AbstractController public function __construct( private readonly UploadHandler $uploadHandler, private readonly EntityManagerInterface $entityManager, - #[Target('disposition')] - private readonly WorkflowInterface $workflow, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggerInterface $logger, ) { @@ -34,48 +30,35 @@ class DocumentUploadController extends AbstractController path: '/administrative/disposition/document-upload/{uuid}', name: 'app_administrative_disposition_document_upload' )] - #[IsGranted('ROLE_ADMINISTRATIVE')] + #[IsGranted('CONTRACT_SUPPLEMENTARY', subject: 'disposition')] public function index(Disposition $disposition, Request $request): Response { - if (true === $this->workflow->can($disposition, 'upload_contract')) { - $uploadType = Upload::TYPE_CONTRACT; - $uploadTypeLabel = 'Honorarvertrag'; - $uploaderRoute = '_uploader_upload_contract'; - } elseif (true === $this->workflow->can($disposition, 'upload_invoice')) { - $uploadType = Upload::TYPE_INVOICE; - $uploadTypeLabel = 'Honoranote'; - $uploaderRoute = '_uploader_upload_invoice'; - } else { - $this->addFlash('error', 'Kein Upload möglich beim aktuellen Status der Einteilung'); - - return $this->getRedirectResponse($disposition); - } - $teamer = $disposition->getTeamer(); - $modalTitle = sprintf('%s für %s hochladen', $uploadTypeLabel, $teamer); + $modalTitle = sprintf('Honorarvertrag für %s hochladen', $teamer); $form = $this->createFormBuilder()->getForm(); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $this->processUpload($disposition, $uploadType); + $this->processUpload($disposition); - $this->addFlash('success', sprintf('%s wurde hochgeladen', $uploadTypeLabel)); + $this->addFlash('success', 'Der Honorarvertrag wurde hochgeladen'); - return $this->getRedirectResponse($disposition); + return new HxRedirectResponse($this->generateUrl('app_administrative_assignment_detail', [ + 'uuid' => $disposition->getAssignment()->getUuid(), + ])); } return $this->render('administrative/disposition/modal_document_upload.html.twig', [ 'disposition' => $disposition, 'modalTitle' => $modalTitle, - 'uploaderRoute' => $uploaderRoute, 'form' => $form->createView(), ]); } - private function processUpload(Disposition $disposition, string $uploadType): void + private function processUpload(Disposition $disposition): void { - if (null !== $existingUpload = $disposition->getDocumentByType($uploadType)) { + if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_CONTRACT)) { $this->entityManager->remove($existingUpload); } @@ -84,17 +67,14 @@ class DocumentUploadController extends AbstractController $uploadSession = $this->uploadHandler->getUploadSession(); - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, $uploadType); - $upload->setStatus(Upload::STATUS_NEW); + $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CONTRACT); + $upload->setStatus(Upload::STATUS_CHECKED); $disposition->addDocument($upload); - $transitionName = sprintf('upload_%s', $uploadType); - $this->workflow->apply($disposition, $transitionName); - $this->entityManager->flush(); - $this->uploadHandler->moveUploadSessionFilesFromOrphanage($uploadType, $uploadSession); + $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CONTRACT, $uploadSession); $this->uploadHandler->destroyUploadSession(); $this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME); @@ -104,14 +84,7 @@ class DocumentUploadController extends AbstractController 'teamer_name' => $teamer->getFullName(), 'disposition_id' => $disposition->getId(), 'document_id' => $upload->getId(), - 'upload_type' => $uploadType, + 'upload_type' => Upload::TYPE_CONTRACT, ]); } - - private function getRedirectResponse(Disposition $disposition): HxRedirectResponse - { - return new HxRedirectResponse($this->generateUrl('app_administrative_assignment_detail', [ - 'uuid' => $disposition->getAssignment()->getUuid(), - ])); - } } diff --git a/src/EventListener/DispositionWorkflowGuardSubscriber.php b/src/EventListener/DispositionWorkflowGuardSubscriber.php index fbc5859..65fe97d 100644 --- a/src/EventListener/DispositionWorkflowGuardSubscriber.php +++ b/src/EventListener/DispositionWorkflowGuardSubscriber.php @@ -4,14 +4,13 @@ namespace App\EventListener; use App\Entity\Disposition; use App\Entity\Upload; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Workflow\Event\GuardEvent; use Symfony\Contracts\Translation\TranslatorInterface; class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface { - public function __construct(private readonly TranslatorInterface $translator, private readonly Security $security) + public function __construct(private readonly TranslatorInterface $translator) {} public static function getSubscribedEvents(): array @@ -41,11 +40,6 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface public function guardUploadContract(GuardEvent $event): void { - // no checks for administrative users - if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) { - return; - } - /** @var Disposition $disposition */ $disposition = $event->getSubject(); $assignment = $disposition->getAssignment(); @@ -85,11 +79,6 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface $event->setBlocked(true, $message); } - // no further checks for administrative users - if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) { - return; - } - $earliestDate = $destination->getDateTo(); if ($earliestDate > $today) { diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 3f2987d..f20b5d6 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -5,6 +5,7 @@ namespace App\Security\Voter; use App\BusProNet\DataProvider\HotelDataProvider; use App\BusProNet\Model\Hotel; use App\Entity\Disposition; +use App\Entity\Upload; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -15,13 +16,12 @@ class DispositionVoter extends Voter public const EDIT = 'EDIT'; public const DELETE = 'DELETE'; public const CONTRACT = 'CONTRACT'; + public const CONTRACT_SUPPLEMENTARY = 'CONTRACT_SUPPLEMENTARY'; public const INVOICE = 'INVOICE'; public const FEEDBACK = 'FEEDBACK'; - public function __construct( - private readonly Security $security, - private readonly HotelDataProvider $hotelDataProvider - ) { + public function __construct(private readonly Security $security) + { } protected function supports(string $attribute, mixed $subject): bool @@ -35,6 +35,7 @@ class DispositionVoter extends Voter static::EDIT, static::DELETE, static::CONTRACT, + static::CONTRACT_SUPPLEMENTARY, static::INVOICE, static::FEEDBACK, ]); @@ -49,6 +50,7 @@ class DispositionVoter extends Voter static::VIEW, static::EDIT, static::CONTRACT, static::INVOICE => $this->assertAdministrativeAccess() || $this->assertTeamerAccess($disposition), static::DELETE => $this->assertAdminAccess(), static::FEEDBACK => $this->assertHouseManagerAccess($token, $disposition), + static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition), default => false, }; } @@ -91,4 +93,14 @@ class DispositionVoter extends Voter return $this->security->getUser()->getTeamer() === $disposition->getTeamer(); } + + private function assertContractUploadAllowed(Disposition $disposition): bool + { + if (false === $this->security->isGranted('ROLE_ADMINISTRATIVE')) { + return false; + } + + return Disposition::STATUS_ENDED === $disposition->getStatus() + && null === $disposition->getDocumentByType(Upload::TYPE_CONTRACT); + } } \ No newline at end of file diff --git a/templates/administrative/assignment/detail.html.twig b/templates/administrative/assignment/detail.html.twig index aece30e..abe5b72 100644 --- a/templates/administrative/assignment/detail.html.twig +++ b/templates/administrative/assignment/detail.html.twig @@ -179,7 +179,7 @@
- {% if workflow_can(disposition, 'upload_contract') or workflow_can(disposition, 'upload_invoice') %} + {% if is_granted('CONTRACT_SUPPLEMENTARY', disposition) %}
{% endblock %}