Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d379a96
make:controller CybersecurityReport
jeppekroghitk Jan 20, 2026
ef6efa2
Added navigation entry alongside translation
jeppekroghitk Jan 20, 2026
521c9c7
Moved cybersecurity report to reports folder
jeppekroghitk Jan 20, 2026
dd50b4f
Removed autogenerated index twig
jeppekroghitk Jan 20, 2026
d90e0f6
Added form and structure to controller
jeppekroghitk Jan 20, 2026
ddbc714
Added service method and data models
jeppekroghitk Jan 20, 2026
167f6e8
Coding standards
jeppekroghitk Jan 21, 2026
c82bde6
Coding standards
jeppekroghitk Jan 21, 2026
c6b2a6b
Coding standards and updated changelog
jeppekroghitk Jan 21, 2026
0116a29
Removed auto generated tests
jeppekroghitk Jan 21, 2026
56fd448
Added versionId to dataProviderIssueData and added version to issue d…
jeppekroghitk Jan 21, 2026
35fbb41
Fixed merge conflict in changelog
jeppekroghitk Jan 21, 2026
594beb1
Merge pull request #265 from itk-dev/hotfix/6262-add-versions-to-issu…
jeppekroghitk Jan 21, 2026
8dad77c
Use version title instead of Version entity in cybersecurity report c…
jeppekroghitk Jan 21, 2026
d0f9cbe
Refactor cybersecurity report form to use version title and submit bu…
jeppekroghitk Jan 21, 2026
81fb882
Align cybersecurity report DTOs with new fields and naming
jeppekroghitk Jan 21, 2026
a6540b8
Query issues by version title instead of Version entity
jeppekroghitk Jan 21, 2026
c122ba7
Add query for projects with cybersecurity agreements
jeppekroghitk Jan 21, 2026
fda4ad8
Add method to fetch worklogs by issue and period
jeppekroghitk Jan 21, 2026
0dcdbd6
Refactor cybersecurity report service to aggregate worklogs by period
jeppekroghitk Jan 21, 2026
0109547
Redesign cybersecurity report view with expandable tickets and worklogs
jeppekroghitk Jan 21, 2026
b46dd68
Add Danish translations for cybersecurity report labels
jeppekroghitk Jan 21, 2026
925fb5e
Enriched and adjusted data and template
jeppekroghitk Jan 22, 2026
6050d46
Coding standards
jeppekroghitk Jan 22, 2026
3e81585
Added comment
jeppekroghitk Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-264](https://github.com/itk-dev/economics/pull/264)
Added cybersecurity report.
* [PR-265](https://github.com/itk-dev/economics/pull/265)
Add version to issue during sync.

## [3.0.0] - 2026-01-20

* [PR-262](https://github.com/itk-dev/economics/pull/262)
Expand Down
4 changes: 4 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@
@apply grid gap-3 mb-6 md:grid-cols-6;
}

#cybersecurity_report {
@apply grid gap-3 mb-6 md:grid-cols-6;
}

.form-default {
@apply grid gap-3 mb-6 md:grid-cols-6;
}
Expand Down
74 changes: 74 additions & 0 deletions src/Controller/CybersecurityReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace App\Controller;

use App\Form\CybersecurityReportType;
use App\Model\Reports\CybersecurityReportFormData;
use App\Repository\DataProviderRepository;
use App\Service\CybersecurityReportService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/reports/cybersecurity_report')]
#[IsGranted('ROLE_REPORT')]
final class CybersecurityReportController extends AbstractController
{
public function __construct(
private readonly DataProviderRepository $dataProviderRepository,
private readonly ?string $defaultDataProvider,
private readonly CybersecurityReportService $cybersecurityReportService,
) {
}

#[Route('/', name: 'app_cybersecurity_report')]
public function index(Request $request): Response
{
$reportData = null;
$reportFormData = new CybersecurityReportFormData();

$dataProvider = null;
$versionTitle = null;

$requestData = $request->query->all('cybersecurity_report');

if (!empty($requestData['dataProvider'])) {
$dataProvider = $this->dataProviderRepository->find($requestData['dataProvider']);
} elseif (null !== $this->defaultDataProvider) {
$dataProvider = $this->dataProviderRepository->find($this->defaultDataProvider);
}

$form = $this->createForm(CybersecurityReportType::class, $reportFormData, [
// Since this is only a filtering form, csrf is not needed.
'csrf_protection' => false,
'action' => $this->generateUrl('app_cybersecurity_report'),
'method' => 'GET',
'attr' => [
'id' => 'cybersecurity_report',
],
'data_provider' => $dataProvider,
'versionTitle' => $versionTitle,
]);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$versionTitle = $form->get('versionTitle')->getData() ?? null;
$fromDate = $form->get('fromDate')->getData() ?? null;
$toDate = $form->get('toDate')->getData() ?? null;

if ($versionTitle && $fromDate && $toDate) {
$reportData = $this->cybersecurityReportService->getCybersecurityReport($fromDate, $toDate, $versionTitle);
}
}

return $this->render('reports/reports.html.twig', [
'controller_name' => 'CybersecurityReportController',
'form' => $form,
'data' => $reportData,
'mode' => 'cybersecurity_report',
]);
}
}
107 changes: 107 additions & 0 deletions src/Form/CybersecurityReportType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace App\Form;

use App\Entity\DataProvider;
use App\Model\Reports\CybersecurityReportFormData;
use App\Repository\DataProviderRepository;
use App\Service\CybersecurityReportService;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CybersecurityReportType extends AbstractType
{
private const string DEFAULT_CYBERSECURITY_MILESTONE = 'Cybersikkerhedsaftale';

public function __construct(
private readonly CybersecurityReportService $cybersecurityReportService,
private readonly DataProviderRepository $dataProviderRepository,
private readonly ?string $defaultDataProvider,
) {
}

/**
* @throws \DateMalformedStringException
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$dataProviders = $this->dataProviderRepository->findAll();
$defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider);

if (null === $defaultProvider && count($dataProviders) > 0) {
$defaultProvider = $dataProviders[0];
}

$builder
->add('dataProvider', EntityType::class, [
'class' => DataProvider::class,
'required' => false,
'label' => 'cybersecurity_report.data_provider',
'label_attr' => ['class' => 'label'],
'placeholder' => 'cybersecurity_report.select_data_provider',
'attr' => [
'class' => 'form-element',
],
'help' => 'cybersecurity_report.data_provider_helptext',
'data' => $defaultProvider,
'choices' => $dataProviders,
])
->add('versionTitle', ChoiceType::class, [
'choices' => [
self::DEFAULT_CYBERSECURITY_MILESTONE => self::DEFAULT_CYBERSECURITY_MILESTONE,
],
'required' => true,
'attr' => [
'class' => 'form-element',
],
'row_attr' => ['class' => 'form-row form-choices'],
'label' => 'cybersecurity_report.versionTitle',
'label_attr' => ['class' => 'label'],
'data' => self::DEFAULT_CYBERSECURITY_MILESTONE,
])
->add('fromDate', DateType::class, [
'widget' => 'single_text',
'input' => 'datetime',
'required' => false,
'label' => 'cybersecurity_report.from_date',
'label_attr' => ['class' => 'label'],
'by_reference' => true,
'data' => $options['fromDate'] ?? $this->cybersecurityReportService->getDefaultFromDate(),
'attr' => [
'class' => 'form-element',
],
])
->add('toDate', DateType::class, [
'widget' => 'single_text',
'input' => 'datetime',
'required' => false,
'label' => 'cybersecurity_report.to_date',
'label_attr' => ['class' => 'label'],
'data' => $options['toDate'] ?? $this->cybersecurityReportService->getDefaultToDate(),
'by_reference' => true,
'attr' => [
'class' => 'form-element',
],
])
->add('submit', SubmitType::class, [
'label' => 'workload_report.submit',
'attr' => [
'class' => 'hour-report-submit button',
],
]);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => CybersecurityReportFormData::class,
])
->setRequired('data_provider')
->setRequired('versionTitle');
}
}
1 change: 1 addition & 0 deletions src/Model/DataProvider/DataProviderIssueData.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function __construct(
public ?\DateTimeInterface $fetchTime,
public ?string $url,
public ?\DateTimeInterface $sourceModifiedDate,
public ?string $versionId,
) {
}
}
18 changes: 18 additions & 0 deletions src/Model/Reports/CybersecurityProjectData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace App\Model\Reports;

final class CybersecurityProjectData
{
/**
* @var CybersecurityTicketData[]
*/
public array $tickets = [];

public function __construct(
public string $projectName,
public float $totalSpent = 0.0,
public bool $hasCybersecurityAgreement = false,
) {
}
}
13 changes: 13 additions & 0 deletions src/Model/Reports/CybersecurityReportData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Model\Reports;

final class CybersecurityReportData
{
/**
* @var array<string, CybersecurityProjectData>
*/
public array $projects = [];

public float $totalSpent = 0.0;
}
13 changes: 13 additions & 0 deletions src/Model/Reports/CybersecurityReportFormData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Model\Reports;

use App\Entity\DataProvider;

class CybersecurityReportFormData
{
public DataProvider $dataProvider;
public string $versionTitle;
public \DateTimeInterface $fromDate;
public \DateTimeInterface $toDate;
}
19 changes: 19 additions & 0 deletions src/Model/Reports/CybersecurityTicketData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Model\Reports;

final class CybersecurityTicketData
{
/**
* @param CybersecurityWorklogData[] $worklogs
*/
public function __construct(
public int $issueId,
public string $projectTrackerId,
public string $headline,
public float $totalSpent,
public string $linkToIssue,
public array $worklogs = [],
) {
}
}
14 changes: 14 additions & 0 deletions src/Model/Reports/CybersecurityWorklogData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Model\Reports;

final class CybersecurityWorklogData
{
public function __construct(
public int $id,
public float $hours,
public ?string $description,
public ?string $worker,
) {
}
}
11 changes: 11 additions & 0 deletions src/Repository/IssueRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,15 @@ public function findIssuesInDateRange(string $startDate, string $endDate, ?Worke

return $query->getResult();
}

public function issuesContainingVersionTitle(string $versionTitle): array
{
return $this->createQueryBuilder('issue')
->select('DISTINCT issue')
->innerJoin('issue.versions', 'version')
->andWhere('version.name = :versionTitle')
->setParameter('versionTitle', $versionTitle)
->getQuery()
->getResult();
}
}
14 changes: 14 additions & 0 deletions src/Repository/ProjectRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Repository;

use App\Entity\Project;
use App\Entity\ServiceAgreement;
use App\Model\Invoices\ProjectFilterData;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
Expand Down Expand Up @@ -105,4 +106,17 @@ public function getProjectTrackerIdsByDataProviders(array $dataProviders)

return $qb->getQuery()->getSingleColumnResult();
}

public function getProjectIdsWithCybersecurityAgreement(): array
{
$result = $this->_em->createQueryBuilder()
->select('DISTINCT p.id')
->from(ServiceAgreement::class, 'sa')
->innerJoin('sa.project', 'p')
->where('sa.cybersecurityAgreement IS NOT NULL')
->getQuery()
->getScalarResult();

return array_column($result, 'id');
}
}
30 changes: 30 additions & 0 deletions src/Repository/WorklogRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,4 +294,34 @@ public function getWorklogsAttachedToInvoiceInDateRange(\DateTimeInterface $peri
'paginator' => $paginator,
];
}

/**
* Get worklogs for a given issue, optionally restricted by period.
*
* @param int $issueId
* @param ?\DateTimeInterface $fromDate
* @param ?\DateTimeInterface $toDate
*
* @return Worklog[]
*/
public function getWorklogsByIssueAndPeriod(int $issueId, ?\DateTimeInterface $fromDate, ?\DateTimeInterface $toDate): array
{
$qb = $this->createQueryBuilder('w')
->andWhere('w.issue = :issue') // <-- use the entity relation
->setParameter('issue', $issueId); // Doctrine can accept the ID for a ManyToOne

if ($fromDate) {
$qb->andWhere('w.started >= :fromDate')
->setParameter('fromDate', $fromDate->format('Y-m-d 00:00:00'));
}

if ($toDate) {
$qb->andWhere('w.started <= :toDate')
->setParameter('toDate', $toDate->format('Y-m-d 23:59:59'));
}

$qb->orderBy('w.started', 'ASC');

return $qb->getQuery()->getResult();
}
}
Loading