diff --git a/.env b/.env index bd009a9..0b6b01c 100644 --- a/.env +++ b/.env @@ -63,3 +63,8 @@ VAULT_SECRET_ID="CHANGE_ME_IN_LOCAL_ENV" # The number of old results for each server/result-type combination APP_KEEP_RESULTS=5 + +###> economics ### +APP_ECONOMICS_URI=https://economics.itkdev.dk +APP_ECONOMICS_API_KEY=changeme +###< economics ### diff --git a/.github/workflows/doctrine.yaml b/.github/workflows/doctrine.yaml new file mode 100644 index 0000000..38276a6 --- /dev/null +++ b/.github/workflows/doctrine.yaml @@ -0,0 +1,64 @@ +name: Doctrine + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + validate-doctrine-schema: + name: Validate Doctrine Schema + runs-on: ubuntu-latest + env: + APP_ENV: prod + + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install + + - name: Run Doctrine Migrations + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + + - name: Setup messenger "failed" doctrine transport to ensure db schema is updated + run: | + docker compose run --rm phpfpm bin/console messenger:setup-transports failed + + - name: Validate Doctrine schema + run: | + docker compose run --rm phpfpm bin/console doctrine:schema:validate + + load-fixtures: + name: Load Doctrine fixtures + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Create docker network + run: | + docker network create frontend + + - name: Run Composer Install + run: | + docker compose run --rm phpfpm composer install --no-interaction + + - name: Run Doctrine Migrations + run: | + docker compose run --rm phpfpm bin/console doctrine:migrations:migrate --no-interaction + + - name: Load fixtures + run: | + docker compose run --rm phpfpm composer fixtures diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5625c95..5b24bf2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -3,24 +3,6 @@ name: Review env: COMPOSE_USER: runner jobs: - validate-doctrine-schema: - runs-on: ubuntu-latest - name: Validate Doctrine Schema - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create docker network - run: docker network create frontend - - - name: Install and validate - run: | - docker compose up --detach - docker compose exec phpfpm composer install --no-interaction - docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - docker compose exec phpfpm bin/console messenger:setup-transports failed - docker compose exec phpfpm bin/console doctrine:schema:validate - phpstan: runs-on: ubuntu-latest name: PHPStan @@ -63,23 +45,6 @@ jobs: fail_ci_if_error: true flags: unittests - fixtures: - runs-on: ubuntu-latest - name: Load fixtures - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create docker network - run: docker network create frontend - - - name: Load fixtures - run: | - docker compose up --detach - docker compose exec phpfpm composer install --no-interaction - docker compose exec phpfpm bin/console doctrine:migrations:migrate --no-interaction - docker compose exec phpfpm composer fixtures - build-assets: runs-on: ubuntu-latest name: Build assets diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d3c760..4e056cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#80](https://github.com/itk-dev/devops_itksites/pull/80) 5566: Service agreements + - Add security contract entity with crud controller + - Add Abstract full crud controller and extend on it in some cases + - Add economics service and sync action/command for service agreement synchronization - [#81](https://github.com/itk-dev/devops_itksites/pull/81) 5564: Asset Mapper migration - Add Symfony Asset Mapper bundle and importmap diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 2c9057c..1aa06cb 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -13,6 +13,14 @@ framework: #esi: true #fragments: true + http_client: + scoped_clients: + economics.client: + base_uri: '%env(APP_ECONOMICS_URI)%' + scope: '%env(APP_ECONOMICS_URI)%' + headers: + x-api-key: '%env(APP_ECONOMICS_API_KEY)%' + when@test: framework: test: true diff --git a/migrations/Version20260520101548.php b/migrations/Version20260520101548.php new file mode 100644 index 0000000..1a0e66f --- /dev/null +++ b/migrations/Version20260520101548.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE security_contract (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) DEFAULT \'\' NOT NULL, modified_by VARCHAR(255) DEFAULT \'\' NOT NULL, economics_id INT NOT NULL, project_name VARCHAR(255) NOT NULL, client_name VARCHAR(255) DEFAULT NULL, hosting_provider VARCHAR(255) DEFAULT NULL, document_url VARCHAR(255) DEFAULT NULL, monthly_price DOUBLE PRECISION DEFAULT NULL, valid_from DATE DEFAULT NULL, valid_to DATE DEFAULT NULL, active TINYINT NOT NULL, eol TINYINT NOT NULL, leantime_url VARCHAR(255) DEFAULT NULL, client_contact_name VARCHAR(255) DEFAULT NULL, client_contact_email VARCHAR(255) DEFAULT NULL, dedicated_server TINYINT NOT NULL, server_size VARCHAR(255) DEFAULT NULL, git_repos LONGTEXT DEFAULT NULL, system_owner_notices JSON DEFAULT NULL, project_tracker_key VARCHAR(255) DEFAULT NULL, quarterly_hours DOUBLE PRECISION DEFAULT NULL, cybersecurity_price DOUBLE PRECISION DEFAULT NULL, cybersecurity_note LONGTEXT DEFAULT NULL, UNIQUE INDEX UNIQ_8AE4AF8B4416F7E8 (economics_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE security_contract'); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fdc5dbc..e1103b1 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,8 @@ parameters: level: 6 + # Require a reason on every @phpstan-ignore so the next reader knows + # why a check was suppressed. https://phpstan.org/user-guide/ignoring-errors#requiring-comments-for-@phpstan-ignore + reportIgnoresWithoutComments: true paths: - bin/ - config/ diff --git a/src/Command/SyncServiceAgreementsCommand.php b/src/Command/SyncServiceAgreementsCommand.php new file mode 100644 index 0000000..55fcb64 --- /dev/null +++ b/src/Command/SyncServiceAgreementsCommand.php @@ -0,0 +1,40 @@ +syncService->syncAll(); + + $io->success(sprintf('Synced %d service agreements successfully.', $count)); + } catch (\Throwable $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Admin/AbstractFullCrudController.php b/src/Controller/Admin/AbstractFullCrudController.php new file mode 100644 index 0000000..10c4c47 --- /dev/null +++ b/src/Controller/Admin/AbstractFullCrudController.php @@ -0,0 +1,65 @@ +showEntityActionsInlined(); + } + + #[\Override] + public function configureActions(Actions $actions): Actions + { + // Remove default actions + $actions + ->remove(Crud::PAGE_INDEX, Action::EDIT) + ->remove(Crud::PAGE_INDEX, Action::DELETE); + + // Re-add default actions as grouped action. + $groupedDefaultActions = ActionGroup::new('default', 'Default') + ->addMainAction( + Action::new('show', 'Show') + ->linkToCrudAction(Action::DETAIL) + ) + ->addAction( + Action::new('edit', 'Edit') + ->linkToCrudAction(Action::EDIT) + ->setIcon('fa fa-edit') + ) + ->addDivider() + ->addAction( + Action::new('delete', 'Delete') + ->linkToCrudAction(Action::DELETE) + ->setIcon('fa fa-trash') + ->setCssClass('btn-danger text-danger') + ); + + return $actions + ->add(Crud::PAGE_INDEX, $groupedDefaultActions) + ->add(Crud::PAGE_INDEX, $this->createExportAction()) + ->update(Crud::PAGE_INDEX, Action::NEW, + static fn (Action $action) => $action->setIcon('fa fa-plus') + ) + ; + } + + #[\Override] + public function configureAssets(Assets $assets): Assets + { + return $assets + ->addWebpackEncoreEntry('easyadmin'); + } +} diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 1e00f92..7044546 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -37,7 +37,7 @@ public function index(): Response public function configureDashboard(): Dashboard { return Dashboard::new() - ->setTitle('ITK sites') + ->setTitle('ITK sites logo') ->setFaviconPath('img/favicon.ico') ->renderContentMaximized(); } @@ -52,6 +52,7 @@ public function configureMenuItems(): iterable yield MenuItem::linkTo(DomainCrudController::class, 'Domains', 'fas fa-link'); yield MenuItem::linkTo(OIDCCrudController::class, 'OIDC', 'fas fa-key'); yield MenuItem::linkTo(ServiceCertificateCrudController::class, 'Service certificates', 'fas fa-lock'); + yield MenuItem::linkTo(SecurityContractCrudController::class, 'Service Agreements', 'fas fa-file-contract'); yield MenuItem::section('Dependencies'); yield MenuItem::linkTo(PackageCrudController::class, 'Packages', 'fas fa-cube'); yield MenuItem::linkTo(PackageVersionCrudController::class, 'Package Versions', 'fas fa-cubes'); diff --git a/src/Controller/Admin/DetectionResultCrudController.php b/src/Controller/Admin/DetectionResultCrudController.php index 5027e68..a206930 100644 --- a/src/Controller/Admin/DetectionResultCrudController.php +++ b/src/Controller/Admin/DetectionResultCrudController.php @@ -36,12 +36,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DockerImageCrudController.php b/src/Controller/Admin/DockerImageCrudController.php index 980c426..dec7b4f 100644 --- a/src/Controller/Admin/DockerImageCrudController.php +++ b/src/Controller/Admin/DockerImageCrudController.php @@ -32,12 +32,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DockerImageTagCrudController.php b/src/Controller/Admin/DockerImageTagCrudController.php index 068318d..246a3d4 100644 --- a/src/Controller/Admin/DockerImageTagCrudController.php +++ b/src/Controller/Admin/DockerImageTagCrudController.php @@ -35,12 +35,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/DomainCrudController.php b/src/Controller/Admin/DomainCrudController.php index d1a98ab..ade3a71 100644 --- a/src/Controller/Admin/DomainCrudController.php +++ b/src/Controller/Admin/DomainCrudController.php @@ -37,14 +37,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE) - ; + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/Controller/Admin/GitRepoCrudController.php b/src/Controller/Admin/GitRepoCrudController.php index 7fa2d4e..562c8c9 100644 --- a/src/Controller/Admin/GitRepoCrudController.php +++ b/src/Controller/Admin/GitRepoCrudController.php @@ -35,12 +35,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/GitTagCrudController.php b/src/Controller/Admin/GitTagCrudController.php index f715f1e..628aa60 100644 --- a/src/Controller/Admin/GitTagCrudController.php +++ b/src/Controller/Admin/GitTagCrudController.php @@ -37,12 +37,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/InstallationCrudController.php b/src/Controller/Admin/InstallationCrudController.php index cad1a95..046774f 100644 --- a/src/Controller/Admin/InstallationCrudController.php +++ b/src/Controller/Admin/InstallationCrudController.php @@ -45,13 +45,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/Controller/Admin/ModuleCrudController.php b/src/Controller/Admin/ModuleCrudController.php index 9cc6a73..311d478 100644 --- a/src/Controller/Admin/ModuleCrudController.php +++ b/src/Controller/Admin/ModuleCrudController.php @@ -31,12 +31,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/ModuleVersionCrudController.php b/src/Controller/Admin/ModuleVersionCrudController.php index 7db17aa..baaacaa 100644 --- a/src/Controller/Admin/ModuleVersionCrudController.php +++ b/src/Controller/Admin/ModuleVersionCrudController.php @@ -34,12 +34,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/OIDCCrudController.php b/src/Controller/Admin/OIDCCrudController.php index 176871e..006c319 100644 --- a/src/Controller/Admin/OIDCCrudController.php +++ b/src/Controller/Admin/OIDCCrudController.php @@ -6,11 +6,7 @@ use App\Entity\OIDC; use App\Repository\SiteRepository; -use App\Trait\ExportCrudControllerTrait; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; @@ -18,10 +14,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use Symfony\Component\Translation\TranslatableMessage; -class OIDCCrudController extends AbstractCrudController +class OIDCCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; - public function __construct( private readonly SiteRepository $siteRepository) { @@ -32,21 +26,6 @@ public static function getEntityFqcn(): string return OIDC::class; } - #[\Override] - public function configureCrud(Crud $crud): Crud - { - return $crud->showEntityActionsInlined(); - } - - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ; - } - #[\Override] public function configureFields(string $pageName): iterable { diff --git a/src/Controller/Admin/PackageCrudController.php b/src/Controller/Admin/PackageCrudController.php index 21fbd74..1e076b4 100644 --- a/src/Controller/Admin/PackageCrudController.php +++ b/src/Controller/Admin/PackageCrudController.php @@ -37,12 +37,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/PackageVersionCrudController.php b/src/Controller/Admin/PackageVersionCrudController.php index 68f521f..49ba5fe 100644 --- a/src/Controller/Admin/PackageVersionCrudController.php +++ b/src/Controller/Admin/PackageVersionCrudController.php @@ -40,12 +40,8 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL); } #[\Override] diff --git a/src/Controller/Admin/SecurityContractCrudController.php b/src/Controller/Admin/SecurityContractCrudController.php new file mode 100644 index 0000000..758851d --- /dev/null +++ b/src/Controller/Admin/SecurityContractCrudController.php @@ -0,0 +1,119 @@ +setDefaultSort(['projectName' => 'ASC']) + ->setSearchFields(['projectName', 'clientName', 'hostingProvider']) + ->showEntityActionsInlined() + ->setPageTitle(Crud::PAGE_INDEX, 'Service Agreements') + ->setHelp(Crud::PAGE_INDEX, 'Service agreements are synced from Economics. Click "Sync all" to update.'); + } + + #[\Override] + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) + ->add(Crud::PAGE_INDEX, Action::DETAIL) + ->add(Crud::PAGE_INDEX, $this->createSyncAllAction()); + } + + #[\Override] + public function configureFields(string $pageName): iterable + { + yield FormField::addFieldset('Project'); + yield BooleanField::new('active')->renderAsSwitch(false)->setColumns(2); + yield BooleanField::new('eol')->setLabel('EOL')->renderAsSwitch(false)->setColumns(2); + yield TextField::new('projectName')->setColumns(8); + yield TextField::new('clientName')->hideOnIndex(); + yield TextField::new('hostingProvider'); + + yield FormField::addFieldset('Links'); + yield UrlField::new('leantimeUrl')->setLabel('Leantime URL')->hideOnIndex(); + yield UrlField::new('documentUrl')->setLabel('Document URL')->hideOnIndex(); + + yield FormField::addFieldset('Contact'); + yield TextField::new('clientContactName')->hideOnIndex(); + yield TextField::new('clientContactEmail')->hideOnIndex(); + + yield FormField::addFieldset('Budget'); + yield NumberField::new('monthlyPrice')->setTextAlign('right')->setColumns(6); + yield NumberField::new('quarterlyHours')->setTextAlign('right')->setColumns(6); + yield NumberField::new('cybersecurityPrice')->setTextAlign('right')->hideOnIndex()->setColumns(6); + yield TextareaField::new('cybersecurityNote')->hideOnIndex()->setColumns(12); + + yield FormField::addFieldset('Infrastructure'); + yield BooleanField::new('dedicatedServer')->renderAsSwitch(false)->hideOnIndex(); + yield TextField::new('serverSize')->hideOnIndex(); + yield TextareaField::new('gitRepos')->hideOnIndex(); + yield TextField::new('projectTrackerKey')->hideOnIndex(); + + yield FormField::addFieldset('Validity'); + yield DateField::new('validFrom')->setColumns(6); + yield DateField::new('validTo')->setColumns(6); + } + + #[AdminRoute] + public function syncAll(): RedirectResponse + { + try { + $count = $this->syncService->syncAll(); + + $this->addFlash('info', sprintf('Synced %d service agreements.', $count)); + } catch (\Throwable $e) { + $this->addFlash('error', sprintf('An error occurred while syncing: %s', $e->getMessage())); + } + + return $this->redirect( + $this->adminUrlGenerator + ->setController(static::class) + ->setAction(Crud::PAGE_INDEX) + ->generateUrl() + ); + } + + private function createSyncAllAction(): Action + { + return Action::new('syncAll', new TranslatableMessage('Sync all'), 'fa fa-rotate') + ->createAsGlobalAction() + ->linkToCrudAction('syncAll') + ->setHtmlAttributes([ + 'onclick' => "const i=this.querySelector('i');if(i){i.classList.add('fa-spin')}this.style.pointerEvents='none';this.style.opacity='0.6'", + ]); + } +} diff --git a/src/Controller/Admin/ServerCrudController.php b/src/Controller/Admin/ServerCrudController.php index a7bbcda..e56522d 100644 --- a/src/Controller/Admin/ServerCrudController.php +++ b/src/Controller/Admin/ServerCrudController.php @@ -9,16 +9,12 @@ use App\Form\Type\Admin\MariaDbVersionFilter; use App\Form\Type\Admin\ServerTypeFilter; use App\Form\Type\Admin\SystemFilter; -use App\Trait\ExportCrudControllerTrait; use App\Types\DatabaseVersionType; use App\Types\HostingProviderType; use App\Types\ServerTypeType; use App\Types\SystemType; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Filters; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; @@ -28,10 +24,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Component\HttpFoundation\RequestStack; -class ServerCrudController extends AbstractCrudController +class ServerCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; - public function __construct( private readonly RequestStack $requestStack, ) { @@ -54,17 +48,6 @@ public function configureCrud(Crud $crud): Crud return $crud; } - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ; - } - #[\Override] public function configureFields(string $pageName): iterable { diff --git a/src/Controller/Admin/ServiceCertificateCrudController.php b/src/Controller/Admin/ServiceCertificateCrudController.php index 8c68f7f..526dbe0 100644 --- a/src/Controller/Admin/ServiceCertificateCrudController.php +++ b/src/Controller/Admin/ServiceCertificateCrudController.php @@ -7,12 +7,7 @@ use App\Entity\ServiceCertificate; use App\Form\Type\ServiceCertificate\ServiceType; use App\Repository\SiteRepository; -use App\Trait\ExportCrudControllerTrait; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; -use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; -use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField; @@ -21,12 +16,11 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField; use Symfony\Component\Translation\TranslatableMessage; -class ServiceCertificateCrudController extends AbstractCrudController +class ServiceCertificateCrudController extends AbstractFullCrudController { - use ExportCrudControllerTrait; - - public function __construct(private readonly SiteRepository $siteRepository) - { + public function __construct( + private readonly SiteRepository $siteRepository, + ) { } public static function getEntityFqcn(): string @@ -46,15 +40,6 @@ public function configureCrud(Crud $crud): Crud ->setSearchFields(['domain', 'name', 'description', 'services.type']); } - #[\Override] - public function configureActions(Actions $actions): Actions - { - return $actions - ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::DELETE); - } - #[\Override] public function configureFields(string $pageName): iterable { @@ -77,7 +62,7 @@ public function configureFields(string $pageName): iterable yield TextField::new('description')->onlyOnIndex() ->setHelp(new TranslatableMessage('Tell what this certificate is used for.'))->setMaxLength(33)->stripTags(); yield UrlField::new('onePasswordUrl') - ->setLabel(new TranslatableMessage('1Password url')); + ->setLabel(new TranslatableMessage('1Password url'))->hideOnIndex(); yield UrlField::new('usageDocumentationUrl')->hideOnIndex() ->setHelp(new TranslatableMessage('Tell where to find documentation on how the certificate is used on the site and how to configure the use.')); yield DateTimeField::new('expirationTime'); @@ -93,11 +78,4 @@ public function configureFields(string $pageName): iterable ->setTemplatePath('service_certificate/services.html.twig') ; } - - #[\Override] - public function configureAssets(Assets $assets): Assets - { - return $assets - ->addWebpackEncoreEntry('easyadmin'); - } } diff --git a/src/Controller/Admin/SiteCrudController.php b/src/Controller/Admin/SiteCrudController.php index 4d49bb4..0287f29 100644 --- a/src/Controller/Admin/SiteCrudController.php +++ b/src/Controller/Admin/SiteCrudController.php @@ -48,13 +48,9 @@ public function configureCrud(Crud $crud): Crud public function configureActions(Actions $actions): Actions { return $actions + ->disable(Action::DELETE, Action::NEW, Action::EDIT) ->add(Crud::PAGE_INDEX, Action::DETAIL) - ->add(Crud::PAGE_INDEX, $this->createExportAction()) - ->remove(Crud::PAGE_INDEX, Action::NEW) - ->remove(Crud::PAGE_INDEX, Action::EDIT) - ->remove(Crud::PAGE_INDEX, Action::DELETE) - ->remove(Crud::PAGE_DETAIL, Action::EDIT) - ->remove(Crud::PAGE_DETAIL, Action::DELETE); + ->add(Crud::PAGE_INDEX, $this->createExportAction()); } #[\Override] diff --git a/src/EasyAdmin/Config/AutoBadgeMenuItem.php b/src/EasyAdmin/Config/AutoBadgeMenuItem.php new file mode 100644 index 0000000..fec38b3 --- /dev/null +++ b/src/EasyAdmin/Config/AutoBadgeMenuItem.php @@ -0,0 +1,31 @@ +crudMenuItem = new CrudMenuItem($label, $icon, $entityFqcn); + } + + public function __call(string $name, array $arguments): mixed + { + return $this->crudMenuItem->$name(...$arguments); + } + + public static function __callStatic(string $name, array $arguments): never + { + throw new \BadMethodCallException(sprintf('Static method %s not implemented', $name)); + } + + public function setBadge(\Stringable|string|int|float|bool|null $content, string $style = 'secondary', array $htmlAttributes = []): self + { + if (!is_int($content)) { + throw new \InvalidArgumentException('The badge content must be an integer'); + } + + if ($content > 0) { + $this->crudMenuItem->setBadge($content, $style, $htmlAttributes); + } + + return $this; + } + + public function getAsDto(): MenuItemDto + { + return $this->crudMenuItem->getAsDto(); + } +} diff --git a/src/Entity/SecurityContract.php b/src/Entity/SecurityContract.php new file mode 100644 index 0000000..390cdd7 --- /dev/null +++ b/src/Entity/SecurityContract.php @@ -0,0 +1,331 @@ +projectName; + } + + public function getEconomicsId(): ?int + { + return $this->economicsId; + } + + public function setEconomicsId(int $economicsId): static + { + $this->economicsId = $economicsId; + + return $this; + } + + public function getProjectName(): string + { + return $this->projectName; + } + + public function setProjectName(string $projectName): static + { + $this->projectName = $projectName; + + return $this; + } + + public function getClientName(): ?string + { + return $this->clientName; + } + + public function setClientName(?string $clientName): static + { + $this->clientName = $clientName; + + return $this; + } + + public function getHostingProvider(): ?string + { + return $this->hostingProvider; + } + + public function setHostingProvider(?string $hostingProvider): static + { + $this->hostingProvider = $hostingProvider; + + return $this; + } + + public function getDocumentUrl(): ?string + { + return $this->documentUrl; + } + + public function setDocumentUrl(?string $documentUrl): static + { + $this->documentUrl = $documentUrl; + + return $this; + } + + public function getMonthlyPrice(): ?float + { + return $this->monthlyPrice; + } + + public function setMonthlyPrice(?float $monthlyPrice): static + { + $this->monthlyPrice = $monthlyPrice; + + return $this; + } + + public function getValidFrom(): ?\DateTimeImmutable + { + return $this->validFrom; + } + + public function setValidFrom(?\DateTimeImmutable $validFrom): static + { + $this->validFrom = $validFrom; + + return $this; + } + + public function getValidTo(): ?\DateTimeImmutable + { + return $this->validTo; + } + + public function setValidTo(?\DateTimeImmutable $validTo): static + { + $this->validTo = $validTo; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } + + public function isEol(): bool + { + return $this->eol; + } + + public function setEol(bool $eol): static + { + $this->eol = $eol; + + return $this; + } + + public function getLeantimeUrl(): ?string + { + return $this->leantimeUrl; + } + + public function setLeantimeUrl(?string $leantimeUrl): static + { + $this->leantimeUrl = $leantimeUrl; + + return $this; + } + + public function getClientContactName(): ?string + { + return $this->clientContactName; + } + + public function setClientContactName(?string $clientContactName): static + { + $this->clientContactName = $clientContactName; + + return $this; + } + + public function getClientContactEmail(): ?string + { + return $this->clientContactEmail; + } + + public function setClientContactEmail(?string $clientContactEmail): static + { + $this->clientContactEmail = $clientContactEmail; + + return $this; + } + + public function isDedicatedServer(): bool + { + return $this->dedicatedServer; + } + + public function setDedicatedServer(bool $dedicatedServer): static + { + $this->dedicatedServer = $dedicatedServer; + + return $this; + } + + public function getServerSize(): ?string + { + return $this->serverSize; + } + + public function setServerSize(?string $serverSize): static + { + $this->serverSize = $serverSize; + + return $this; + } + + public function getGitRepos(): ?string + { + return $this->gitRepos; + } + + public function setGitRepos(?string $gitRepos): static + { + $this->gitRepos = $gitRepos; + + return $this; + } + + public function getSystemOwnerNotices(): ?array + { + return $this->systemOwnerNotices; + } + + public function setSystemOwnerNotices(?array $systemOwnerNotices): static + { + $this->systemOwnerNotices = $systemOwnerNotices; + + return $this; + } + + public function getProjectTrackerKey(): ?string + { + return $this->projectTrackerKey; + } + + public function setProjectTrackerKey(?string $projectTrackerKey): static + { + $this->projectTrackerKey = $projectTrackerKey; + + return $this; + } + + public function getQuarterlyHours(): ?float + { + return $this->quarterlyHours; + } + + public function setQuarterlyHours(?float $quarterlyHours): static + { + $this->quarterlyHours = $quarterlyHours; + + return $this; + } + + public function getCybersecurityPrice(): ?float + { + return $this->cybersecurityPrice; + } + + public function setCybersecurityPrice(?float $cybersecurityPrice): static + { + $this->cybersecurityPrice = $cybersecurityPrice; + + return $this; + } + + public function getCybersecurityNote(): ?string + { + return $this->cybersecurityNote; + } + + public function setCybersecurityNote(?string $cybersecurityNote): static + { + $this->cybersecurityNote = $cybersecurityNote; + + return $this; + } +} diff --git a/src/Repository/OIDCRepository.php b/src/Repository/OIDCRepository.php index cfe5a56..09171d2 100644 --- a/src/Repository/OIDCRepository.php +++ b/src/Repository/OIDCRepository.php @@ -40,4 +40,14 @@ public function remove(OIDC $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } + + public function countExpiredCertificates(): int + { + return $this->createQueryBuilder('o') + ->select('COUNT(o)') + ->where('o.expirationTime < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Repository/SecurityContractRepository.php b/src/Repository/SecurityContractRepository.php new file mode 100644 index 0000000..d33baf8 --- /dev/null +++ b/src/Repository/SecurityContractRepository.php @@ -0,0 +1,54 @@ + + */ +class SecurityContractRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SecurityContract::class); + } + + // /** + // * @return SecurityContract[] Returns an array of SecurityContract objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?SecurityContract + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } + + public function countExpiredContracts(): int + { + return $this->createQueryBuilder('c') + ->select('COUNT(c)') + ->where('c.validTo < :now') + ->andWhere('c.active = true') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Repository/ServiceCertificateRepository.php b/src/Repository/ServiceCertificateRepository.php index 219da2b..bbd1370 100644 --- a/src/Repository/ServiceCertificateRepository.php +++ b/src/Repository/ServiceCertificateRepository.php @@ -40,4 +40,14 @@ public function remove(ServiceCertificate $entity, bool $flush = false): void $this->getEntityManager()->flush(); } } + + public function countExpiredCertificates(): int + { + return $this->createQueryBuilder('c') + ->select('COUNT(c)') + ->where('c.expirationTime < :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getSingleScalarResult(); + } } diff --git a/src/Service/ServiceAgreementSyncService.php b/src/Service/ServiceAgreementSyncService.php new file mode 100644 index 0000000..4c7be95 --- /dev/null +++ b/src/Service/ServiceAgreementSyncService.php @@ -0,0 +1,134 @@ +economicsClient->request('GET', '/api/serviceagreements'); + $agreements = $response->toArray(); + } catch (ExceptionInterface $e) { + throw new \RuntimeException('Failed to fetch service agreements from Economics API: '.$e->getMessage(), 0, $e); + } + + $existingContracts = $this->entityManager->getRepository(SecurityContract::class)->findAll(); + $existingByEconomicsId = []; + foreach ($existingContracts as $contract) { + $existingByEconomicsId[$contract->getEconomicsId()] = $contract; + } + + $seenIds = []; + + foreach ($agreements as $data) { + $economicsId = $data['id']; + $seenIds[] = $economicsId; + + $contract = $existingByEconomicsId[$economicsId] ?? new SecurityContract(); + + $this->mapDataToContract($contract, $data); + + if (null === $contract->getEconomicsId()) { + $contract->setEconomicsId($economicsId); + } + + $this->entityManager->persist($contract); + } + + foreach ($existingContracts as $contract) { + if (!in_array($contract->getEconomicsId(), $seenIds, true)) { + $this->entityManager->remove($contract); + } + } + + $this->entityManager->flush(); + + return count($agreements); + } + + /** + * Map API response data onto a SecurityContract entity. + * + * @param array $data a single service agreement from the API + * + * @throws \Exception + */ + private function mapDataToContract(SecurityContract $contract, array $data): void + { + $contract->setEconomicsId($data['id']); + $contract->setProjectName($data['projectName'] ?? ''); + $contract->setClientName($data['clientName'] ?? null); + $contract->setHostingProvider($data['hostingProvider'] ?? null); + $contract->setDocumentUrl($data['documentUrl'] ?? null); + $contract->setMonthlyPrice(isset($data['price']) ? (float) $data['price'] : null); + $contract->setValidFrom($this->parseDate($data['validFrom'] ?? null)); + $contract->setValidTo($this->parseDate($data['validTo'] ?? null)); + $contract->setActive($data['isActive'] ?? false); + $contract->setEol($data['isEol'] ?? false); + $contract->setLeantimeUrl($data['leantimeUrl'] ?? null); + $contract->setClientContactName($data['clientContactName'] ?? null); + $contract->setClientContactEmail($data['clientContactEmail'] ?? null); + $contract->setDedicatedServer($data['dedicatedServer'] ?? false); + $contract->setServerSize($data['serverSize'] ?? null); + $contract->setGitRepos($data['gitRepos'] ?? null); + $contract->setSystemOwnerNotices($data['systemOwnerNotices'] ?? null); + $contract->setProjectTrackerKey($data['projectTrackerKey'] ?? null); + + $cyber = $data['cybersecurityAgreement'] ?? null; + if (is_array($cyber)) { + $contract->setQuarterlyHours(isset($cyber['quarterlyHours']) ? (float) $cyber['quarterlyHours'] : null); + $contract->setCybersecurityPrice(isset($cyber['price']) ? (float) $cyber['price'] : null); + $contract->setCybersecurityNote($cyber['note'] ?? null); + } + } + + /** + * Parse the Economics API date format ({date, timezone_type, timezone}) into a DateTimeImmutable. + * + * @param array{date?: string, timezone_type?: int, timezone?: string}|null $dateData raw date object from the API + * + * @return \DateTimeImmutable|null the parsed date, or null if no valid date data was provided + * + * @throws \Exception if the date string cannot be parsed + */ + private function parseDate(?array $dateData): ?\DateTimeImmutable + { + if (null === $dateData || !isset($dateData['date'])) { + return null; + } + + $timezone = new \DateTimeZone($dateData['timezone'] ?? 'UTC'); + + return new \DateTimeImmutable($dateData['date'], $timezone); + } +} diff --git a/src/Trait/ExportCrudControllerTrait.php b/src/Trait/ExportCrudControllerTrait.php index 04fceac..71f5c2f 100644 --- a/src/Trait/ExportCrudControllerTrait.php +++ b/src/Trait/ExportCrudControllerTrait.php @@ -35,7 +35,7 @@ public function setExporter(Exporter $exporter): void protected function createExportAction(string|TranslatableMessage|null $label = null): Action { - return Action::new('export', $label ?? new TranslatableMessage('Export')) + return Action::new('export', $label ?? new TranslatableMessage('Export'), 'fa fa-file-csv') ->createAsGlobalAction() ->linkToCrudAction('export'); } diff --git a/templates/EasyAdminBundle/Fields/eol.html.twig b/templates/EasyAdminBundle/Fields/eol.html.twig index 351ff76..91efa11 100644 --- a/templates/EasyAdminBundle/Fields/eol.html.twig +++ b/templates/EasyAdminBundle/Fields/eol.html.twig @@ -3,6 +3,7 @@ {# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} {% set slicedDate = field.formattedValue|slice(0, 7) %} + {% if ea().crud.currentAction == 'detail' %} {% set outputValue = field.formattedValue %} {% else %}