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('
')
+ ->setTitle('
')
->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 %}