diff --git a/.env b/.env index bd009a9..0fa1f32 100644 --- a/.env +++ b/.env @@ -2,6 +2,7 @@ COMPOSE_PROJECT_NAME=itksites COMPOSE_DOMAIN=itksites.local.itkdev.dk ITKDEV_TEMPLATE=symfony-8 + # In all environments, the following files are loaded if they exist, # the latter taking precedence over the former: # @@ -63,3 +64,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/apispec.yaml b/.github/workflows/apispec.yaml new file mode 100644 index 0000000..7d11a53 --- /dev/null +++ b/.github/workflows/apispec.yaml @@ -0,0 +1,115 @@ +name: API Spec review + +env: + COMPOSE_USER: root + +on: + pull_request: + +jobs: + api-spec-updated: + name: Ensure committed API specification is up to date + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 2 + + - name: Create docker network + run: | + docker network create frontend + + - name: Composer install + run: | + docker compose run --rm phpfpm composer install + + - name: Export API specification + run: | + docker compose run --rm phpfpm bin/console api:openapi:export --yaml --output=public/api-spec-v1.yaml --no-interaction + + - name: Check for changes in specification + id: git-diff-spec + continue-on-error: true + run: git diff --diff-filter=ACMRT --exit-code public/api-spec-v1.yaml + + - name: Comment PR + if: steps.git-diff-spec.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + run: | + echo '## πŸ›‘ Exported API specification file not up to date' > var/comment.md + echo '' >> var/comment.md + echo 'Please run `composer update-api-spec` to export the API specification. Then commit and push the changes.' >> var/comment.md + gh pr comment ${{ github.event.pull_request.number }} --body-file var/comment.md --create-if-none --edit-last + + - name: Fail job, api spec is not up to date + if: steps.git-diff-spec.outcome == 'failure' + run: | + exit 1 + + detect-breaking-changes: + name: Detect breaking changes in API specification + runs-on: ubuntu-latest + needs: [api-spec-updated] + steps: + - name: Check out BASE rev + uses: actions/checkout@v5 + with: + ref: ${{ github.base_ref }} + path: base + + - name: Check out HEAD rev + uses: actions/checkout@v5 + with: + ref: ${{ github.head_ref }} + path: head + + - name: Run OpenAPI Changed (from HEAD rev) + id: api-changed + continue-on-error: true + uses: docker://openapitools/openapi-diff:latest + with: + args: --fail-on-changed base/public/api-spec-v1.yaml head/public/api-spec-v1.yaml --markdown api-spec-changed.md + + - name: Run OpenAPI Incompatible (from HEAD rev) + id: api-incompatible + continue-on-error: true + uses: docker://openapitools/openapi-diff:latest + with: + args: --fail-on-incompatible base/public/api-spec-v1.yaml head/public/api-spec-v1.yaml --markdown api-spec-incompatible.md + + - name: Comment PR with no changes + if: steps.api-changed.outcome == 'success' && steps.api-incompatible.outcome == 'success' + working-directory: head + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} --body "βœ… **No changes detected in API specification**" --create-if-none --edit-last + + - name: Comment PR with non-breaking changes + if: steps.api-changed.outcome == 'failure' && steps.api-incompatible.outcome == 'success' + working-directory: head + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "## ⚠️ Non-Breaking changes detected in API specification" > ../comment.md + echo "" >> ../comment.md + cat ../api-spec-changed.md >> ../comment.md + gh pr comment ${{ github.event.pull_request.number }} --body-file ../comment.md --create-if-none --edit-last + + - name: Comment PR with breaking changes + if: steps.api-incompatible.outcome == 'failure' + working-directory: head + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "## πŸ›‘ Breaking changes detected in API specification" > ../comment.md + echo "" >> ../comment.md + cat ../api-spec-incompatible.md >> ../comment.md + gh pr comment ${{ github.event.pull_request.number }} --body-file ../comment.md --create-if-none --edit-last + + - name: Fail if breaking changes detected + if: steps.api-incompatible.outcome == 'failure' + run: | + exit 1 diff --git a/.github/workflows/doctrine.yaml b/.github/workflows/doctrine.yaml new file mode 100644 index 0000000..696886d --- /dev/null +++ b/.github/workflows/doctrine.yaml @@ -0,0 +1,41 @@ +name: Doctrine + +env: + COMPOSE_USER: root + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + coding-standards: + name: Validate Schema + runs-on: ubuntu-latest + env: + APP_ENV: prod + + steps: + - uses: actions/checkout@v5 + + - 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 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 5625c95..7bb6354 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,8 +1,40 @@ -on: pull_request name: Review env: COMPOSE_USER: runner +on: pull_request + jobs: + test-suite: + name: Test suite + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Task + uses: go-task/setup-task@v1 + + - name: Setup docker network + run: | + docker network create frontend + + - name: Install Site + run: | + task site:update + task site:migrate + task fixtures:load -y + + - name: Test suite + run: | + task test:matrix + + - name: Upload coverage to Codecov test + uses: codecov/codecov-action@v5 + with: + files: ./coverage/unit.xml + flags: unittests + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + validate-doctrine-schema: runs-on: ubuntu-latest name: Validate Doctrine Schema diff --git a/.gitignore b/.gitignore index 64c6627..3638608 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,17 @@ yarn-error.log phpstan.neon ###< phpstan/phpstan ### .phpunit.cache + +###> vincentlanglet/twig-cs-fixer ### +/.twig-cs-fixer.cache +###< vincentlanglet/twig-cs-fixer ### + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### +coverage + .twig-cs-fixer.cache .playwright-mcp + diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9acdc60..84ac3b4 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -9,6 +9,7 @@ $finder->in(__DIR__); // … that are not ignored by VCS $finder->ignoreVCSIgnored(true); + // Exclude generated files $finder->notPath('config/reference.php'); diff --git a/.prettierignore b/.prettierignore index 41e20a0..4ec364e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,6 @@ +# API spec + # Generated API specification files public/api-spec-v1.yaml public/api-spec-v1.json + diff --git a/CHANGELOG.md b/CHANGELOG.md index 328f16a..aa5131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- [#61](https://github.com/itk-dev/devops_itksites/pull/61) 5566: Service agreements + - Remove project entity + - Remove leantime service + - 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 + +- [#60](https://github.com/itk-dev/devops_itksites/pull/60) 5564: Dependency updates + - Update dependencies + - Update phpunit from 11 to 12 + - Update ITK docker template + - Update github actions workflows + +## [1.11.0] - 2026-05-19 + - [#78](https://github.com/itk-dev/devops_itksites/pull/78) Update composer dependencies, fix php-cs-fixer deprecation - [#77](https://github.com/itk-dev/devops_itksites/pull/77) @@ -174,7 +189,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2022-09-15 -[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.10.0...HEAD +[Unreleased]: https://github.com/itk-dev/devops_itksites/compare/1.11.0...HEAD +[1.11.0]: https://github.com/itk-dev/devops_itksites/compare/1.10.1...1.11.0 +[1.10.1]: https://github.com/itk-dev/devops_itksites/compare/1.10.0...1.10.1 [1.10.0]: https://github.com/itk-dev/devops_itksites/compare/1.9.2...1.10.0 [1.9.2]: https://github.com/itk-dev/devops_itksites/compare/1.9.1...1.9.2 [1.9.1]: https://github.com/itk-dev/devops_itksites/compare/1.9.0...1.9.1 diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..e2824a5 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,9 @@ +/* + * Welcome to your app's main JavaScript file! + * + * This file will be included onto the page via the importmap() Twig function, + * which should already be in your base.html.twig. + */ +import "./styles/app.css"; + +console.log("This log comes from assets/app.js - welcome to AssetMapper! πŸŽ‰"); diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..8218dc7 --- /dev/null +++ b/assets/styles/app.css @@ -0,0 +1,28 @@ +:root { + --body-max-width: 100%; + --sidebar-bg: #fff; + /* make the base font size smaller */ + --button-primary-bg: rgb(0, 123, 166); + --pagination-active-bg: rgb(0, 123, 166); + --link-color: rgb(0, 123, 166); + --sidebar-menu-active-item-color: rgb(0, 123, 166); + --badge-boolean-true-bg: rgb(0, 123, 166); + --badge-boolean-false-bg: rgb(228, 73, 48); + --badge-boolean-false-color: var(--white); + --sidebar-menu-color: rgb(66, 66, 66); + --text-color-dark: rgb(66, 66, 66); + --bs-danger-rgb: 228, 73, 48; +} + +/* Grouped dropdown group styling for index pages */ +.dropdown-menu { + .btn-danger i, + .text-danger i { + color: var(--button-invisible-danger-color); + } + + a.btn-danger:hover, + a.text-danger:hover { + background: var(--button-invisible-danger-hover-hover-bg); + } +} diff --git a/composer.json b/composer.json index 1737658..efe1ca0 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "phpstan/phpdoc-parser": "^2.0", "symfony/amqp-messenger": "^8.0", "symfony/asset": "^8.0", + "symfony/asset-mapper": "~8.0.0", "symfony/browser-kit": "^8.0", "symfony/console": "^8.0", "symfony/doctrine-messenger": "^8.0", @@ -111,7 +112,8 @@ ], "auto-scripts": { "cache:clear": "symfony-cmd", - "assets:install %PUBLIC_DIR%": "symfony-cmd" + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" }, "coding-standards-apply": [ "vendor/bin/php-cs-fixer fix" diff --git a/composer.lock b/composer.lock index 9392a9d..277f9e3 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": "d3d28912efc62de1dcb6b25446712071", + "content-hash": "2a1b30ad222f086b612ec724b183c9eb", "packages": [ { "name": "api-platform/core", @@ -3317,6 +3317,87 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/asset-mapper", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset-mapper.git", + "reference": "b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c", + "reference": "b2c33bf6934bfe5b37a6d70d0b0f7011d0ec4a0c", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "php": ">=8.4", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0" + }, + "require-dev": { + "symfony/asset": "^7.4|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/event-dispatcher-contracts": "^3.0", + "symfony/finder": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/web-link": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\AssetMapper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset-mapper/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, { "name": "symfony/browser-kit", "version": "v8.0.8", diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 0000000..f7653e9 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn 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/config/packages/webpack_encore.yaml b/config/packages/webpack_encore.yaml index 6583378..0627dee 100644 --- a/config/packages/webpack_encore.yaml +++ b/config/packages/webpack_encore.yaml @@ -33,7 +33,6 @@ webpack_encore: framework: assets: json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' - #when@prod: # webpack_encore: # # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) diff --git a/config/reference.php b/config/reference.php index a632abf..563c307 100644 --- a/config/reference.php +++ b/config/reference.php @@ -280,7 +280,7 @@ * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * paths?: string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true diff --git a/importmap.php b/importmap.php new file mode 100644 index 0000000..70ebf14 --- /dev/null +++ b/importmap.php @@ -0,0 +1,19 @@ + [ + 'path' => './assets/app.js', + 'entrypoint' => true, + ], +]; diff --git a/migrations/Version20251023091529.php b/migrations/Version20251023091529.php new file mode 100644 index 0000000..f1a4fea --- /dev/null +++ b/migrations/Version20251023091529.php @@ -0,0 +1,75 @@ +addSql(<<<'SQL' + CREATE TABLE project ( + 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, + leantime_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + leantime_url VARCHAR(255) DEFAULT NULL, + economics_url VARCHAR(255) DEFAULT NULL, + details LONGTEXT DEFAULT NULL, + leantime_modified_at DATETIME NOT NULL, + UNIQUE INDEX UNIQ_2FB3D0EE4B785F0C (leantime_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + 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_report_url VARCHAR(255) DEFAULT NULL, + operational_contract_url LONGTEXT DEFAULT NULL, + monthly_price DOUBLE PRECISION DEFAULT NULL, + quarterly_hours DOUBLE PRECISION DEFAULT NULL, + valid_from DATE NOT NULL, + valid_to DATE NOT NULL, + active TINYINT(1) NOT NULL, + notes LONGTEXT NOT NULL, + project_id BINARY(16) NOT NULL, + UNIQUE INDEX UNIQ_8AE4AF8B166D1F9C (project_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + security_contract + ADD + CONSTRAINT FK_8AE4AF8B166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE security_contract DROP FOREIGN KEY FK_8AE4AF8B166D1F9C'); + $this->addSql('DROP TABLE project'); + $this->addSql('DROP TABLE security_contract'); + } +} diff --git a/migrations/Version20260519061123.php b/migrations/Version20260519061123.php new file mode 100644 index 0000000..4c04db0 --- /dev/null +++ b/migrations/Version20260519061123.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE security_contract DROP FOREIGN KEY `FK_8AE4AF8B166D1F9C`'); + $this->addSql('DROP TABLE project'); + $this->addSql('DROP INDEX UNIQ_8AE4AF8B166D1F9C ON security_contract'); + $this->addSql('ALTER TABLE security_contract ADD economics_id INT NOT NULL, ADD project_name VARCHAR(255) NOT NULL, ADD hosting_provider VARCHAR(255) DEFAULT NULL, ADD document_url VARCHAR(255) DEFAULT NULL, ADD eol TINYINT NOT NULL, ADD leantime_url VARCHAR(255) DEFAULT NULL, ADD client_contact_name VARCHAR(255) DEFAULT NULL, ADD client_contact_email VARCHAR(255) DEFAULT NULL, ADD dedicated_server TINYINT NOT NULL, ADD server_size VARCHAR(255) DEFAULT NULL, ADD system_owner_notices JSON DEFAULT NULL, ADD project_tracker_key VARCHAR(255) DEFAULT NULL, ADD cybersecurity_price DOUBLE PRECISION DEFAULT NULL, ADD cybersecurity_note LONGTEXT DEFAULT NULL, DROP notes, DROP project_id, CHANGE valid_from valid_from DATE DEFAULT NULL, CHANGE valid_to valid_to DATE DEFAULT NULL, CHANGE economics_report_url client_name VARCHAR(255) DEFAULT NULL, CHANGE operational_contract_url git_repos LONGTEXT DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8AE4AF8B4416F7E8 ON security_contract (economics_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE TABLE project (id BINARY(16) NOT NULL, created_at DATETIME NOT NULL, modified_at DATETIME NOT NULL, created_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, modified_by VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'\' NOT NULL COLLATE `utf8mb4_unicode_ci`, leantime_id INT NOT NULL, name VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, leantime_url VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, economics_url VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, details LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, leantime_modified_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_2FB3D0EE4B785F0C (leantime_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' '); + $this->addSql('DROP INDEX UNIQ_8AE4AF8B4416F7E8 ON security_contract'); + $this->addSql('ALTER TABLE security_contract ADD economics_report_url VARCHAR(255) DEFAULT NULL, ADD operational_contract_url LONGTEXT DEFAULT NULL, ADD notes LONGTEXT NOT NULL, ADD project_id BINARY(16) NOT NULL, DROP economics_id, DROP project_name, DROP client_name, DROP hosting_provider, DROP document_url, DROP eol, DROP leantime_url, DROP client_contact_name, DROP client_contact_email, DROP dedicated_server, DROP server_size, DROP git_repos, DROP system_owner_notices, DROP project_tracker_key, DROP cybersecurity_price, DROP cybersecurity_note, CHANGE valid_from valid_from DATE NOT NULL, CHANGE valid_to valid_to DATE NOT NULL'); + $this->addSql('ALTER TABLE security_contract ADD CONSTRAINT `FK_8AE4AF8B166D1F9C` FOREIGN KEY (project_id) REFERENCES project (id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8AE4AF8B166D1F9C ON security_contract (project_id)'); + } +} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index bedf5ac..0000000 --- a/psalm.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/public/css/admin.css b/public/css/admin.css deleted file mode 100644 index 853c0f8..0000000 --- a/public/css/admin.css +++ /dev/null @@ -1,15 +0,0 @@ -:root { - --body-max-width: 100%; - --sidebar-bg: #fff; - /* make the base font size smaller */ - --button-primary-bg: rgb(0,123,166); - --pagination-active-bg: rgb(0,123,166); - --link-color: rgb(0,123,166); - --sidebar-menu-active-item-color: rgb(0,123,166); - --badge-boolean-true-bg: rgb(0,123,166); - --badge-boolean-false-bg: rgb(228, 73, 48); - --badge-boolean-false-color: var(--white); - --sidebar-menu-color: rgb(66,66,66); - --text-color-dark: rgb(66,66,66); - --bs-danger-rgb: 228, 73, 48; -} \ No newline at end of file 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..883b425 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'); @@ -80,6 +81,6 @@ public function configureCrud(): Crud #[\Override] public function configureAssets(): Assets { - return Assets::new()->addCssFile('css/admin.css'); + return parent::configureAssets()->addAssetMapperEntry('app'); } } 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..9ba8ca1 --- /dev/null +++ b/src/EasyAdmin/Config/AutoBadgeMenuItem.php @@ -0,0 +1,29 @@ +crudMenuItem = new CrudMenuItem($label, $icon, $entityFqcn); + } + + /** @phpstan-ignore missingType.return, missingType.parameter, missingType.parameter */ + public function __call($name, $arguments) + { + return $this->crudMenuItem->$name(...$arguments); + } + + /** @phpstan-ignore missingType.return */ + public static function __callStatic(string $name, array $arguments) + { + throw new \BadMethodCallException(sprintf('Static method %s not implemented', $name)); + } + + /** @phpstan-ignore missingType.parameter */ + 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/symfony.lock b/symfony.lock index 1033474..4e95b38 100644 --- a/symfony.lock +++ b/symfony.lock @@ -307,6 +307,21 @@ "symfony/asset": { "version": "v6.0.3" }, + "symfony/asset-mapper": { + "version": "7.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a" + }, + "files": [ + "assets/app.js", + "assets/styles/app.css", + "config/packages/asset_mapper.yaml", + "importmap.php" + ] + }, "symfony/browser-kit": { "version": "v6.0.3" }, 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 %} diff --git a/templates/base.html.twig b/templates/base.html.twig index d4f83f7..a4f2bfe 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -10,6 +10,7 @@ {% endblock %} {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} {{ encore_entry_script_tags('app') }} {% endblock %} diff --git a/templates/bundles/EasyAdminBundle/crud/detail.html.twig b/templates/bundles/EasyAdminBundle/crud/detail.html.twig new file mode 100644 index 0000000..9596b80 --- /dev/null +++ b/templates/bundles/EasyAdminBundle/crud/detail.html.twig @@ -0,0 +1,40 @@ +{% extends '@!EasyAdmin/crud/detail.html.twig' %} + +{% block main %} + {{ parent() }} + +
+
+
+ + + + Notes + + + +
+
+ +
+
+ +
+
+ Notes +
+
+ +
+ + nfkngjkfdsnds + + +
+
+ +
+
+
+ +{% endblock %}