From 67767310d3c2d8edc062bd16231e0d43a326b1d3 Mon Sep 17 00:00:00 2001 From: afornerot Date: Sun, 3 Aug 2025 22:42:14 +0200 Subject: [PATCH] svg --- composer.json | 8 +- composer.lock | 17 +- config/services.yaml | 1 - src/Controller/ProjectController.php | 2 +- src/Entity/Project.php | 86 ++++++++-- src/Entity/ProjectTimeline.php | 86 ++++++++++ src/EventListener/ProjectListener.php | 155 +++++++++++++++++++ src/Form/ProjectType.php | 28 +++- src/Repository/ProjectTimelineRepository.php | 18 +++ src/Security/ProjectVoter.php | 60 +++++++ symfony.lock | 2 +- templates/base.html.twig | 5 +- templates/home/home.html.twig | 23 ++- templates/project/edit.html.twig | 27 ++-- templates/project/list.html.twig | 2 +- templates/project/timeline.html.twig | 57 +++++++ 16 files changed, 532 insertions(+), 45 deletions(-) create mode 100644 src/Entity/ProjectTimeline.php create mode 100644 src/EventListener/ProjectListener.php create mode 100644 src/Repository/ProjectTimelineRepository.php create mode 100644 src/Security/ProjectVoter.php create mode 100644 templates/project/timeline.html.twig diff --git a/composer.json b/composer.json index 419c0b0..a56a3f7 100644 --- a/composer.json +++ b/composer.json @@ -3,19 +3,13 @@ "license": "proprietary", "minimum-stability": "stable", "prefer-stable": true, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/afornerot/bNine-MdEditorBundle" - } - ], "require": { "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", "apereo/phpcas": "^1.6", "bnine/filesbundle": "^1.0", - "bnine/mdeditorbundle": "*", + "bnine/mdeditorbundle": "^1.1", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-migrations-bundle": "^3.3", diff --git a/composer.lock b/composer.lock index f85fe90..8087719 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": "6f7395c5fc09ba0bc1a2325b862a69c7", + "content-hash": "2d59dc750783494ae1dbf58d9a3025c3", "packages": [ { "name": "apereo/phpcas", @@ -140,16 +140,16 @@ }, { "name": "bnine/mdeditorbundle", - "version": "v0.1.5", + "version": "v1.1.3", "source": { "type": "git", "url": "https://github.com/afornerot/bNine-MdEditorBundle.git", - "reference": "b5897339b8aed8f32578b3ebdf1e9ffa4ef83fa0" + "reference": "ad2c2829177a6788452fde1d70170ba5c5c6d0d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/afornerot/bNine-MdEditorBundle/zipball/b5897339b8aed8f32578b3ebdf1e9ffa4ef83fa0", - "reference": "b5897339b8aed8f32578b3ebdf1e9ffa4ef83fa0", + "url": "https://api.github.com/repos/afornerot/bNine-MdEditorBundle/zipball/ad2c2829177a6788452fde1d70170ba5c5c6d0d6", + "reference": "ad2c2829177a6788452fde1d70170ba5c5c6d0d6", "shasum": "" }, "require": { @@ -173,6 +173,7 @@ "Bnine\\MdEditorBundle\\": "src/" } }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -191,10 +192,10 @@ "symfony" ], "support": { - "source": "https://github.com/afornerot/bNine-MdEditorBundle/tree/v0.1.5", - "issues": "https://github.com/afornerot/bNine-MdEditorBundle/issues" + "issues": "https://github.com/afornerot/bNine-MdEditorBundle/issues", + "source": "https://github.com/afornerot/bNine-MdEditorBundle/tree/v1.1.3" }, - "time": "2025-08-02T20:06:19+00:00" + "time": "2025-08-02T20:38:10+00:00" }, { "name": "brick/math", diff --git a/config/services.yaml b/config/services.yaml index 97589a9..60c2f94 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -29,4 +29,3 @@ services: App\Security\DynamicAuthenticator: arguments: $modeAuth: '%env(MODE_AUTH)%' - diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 0b6a696..826eb01 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -78,7 +78,7 @@ class ProjectController extends AbstractController if ($form->isSubmitted() && $form->isValid()) { $em->flush(); - return $this->redirectToRoute('app_admin_project'); + // return $this->redirectToRoute('app_admin_project'); } return $this->render('project/edit.html.twig', [ diff --git a/src/Entity/Project.php b/src/Entity/Project.php index c3ef12e..9291f5f 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -8,8 +8,18 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ProjectRepository::class)] +#[ORM\HasLifecycleCallbacks] class Project { + public const DRAFT = 'Brouillon'; + public const TOVOTE = 'A Voter'; + public const VOTED = 'Voté'; + public const ARCHIVED = 'Archivé'; + + public const NATURE_COLLECTIVE = 'Collective'; + public const NATURE_STRATEGIC = 'Stratégique'; + public const NATURE_TACTICAL = 'Tactique'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -18,11 +28,20 @@ class Project #[ORM\Column(length: 255, unique: true)] private ?string $title = null; - #[ORM\Column()] - private int $status; + #[ORM\Column(type: 'text')] + private string $summary; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description = null; + + #[ORM\Column(type: 'string', length: 20)] + private string $nature; + + #[ORM\Column] + private string $status; #[ORM\Column(type: 'datetime', nullable: true)] - private ?\DateTimeInterface $updateAt = null; + private ?\DateTimeInterface $dueDate = null; /** * @var Collection @@ -30,9 +49,17 @@ class Project #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'projects')] private Collection $users; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectTimeline::class, cascade: ['remove'])] + #[ORM\OrderBy(['createdAt' => 'DESC'])] + private Collection $timelines; + public function __construct() { $this->users = new ArrayCollection(); + $this->timelines = new ArrayCollection(); } public function getId(): ?int @@ -52,30 +79,66 @@ class Project return $this; } - public function getUpdateAt(): ?\DateTimeInterface + public function getSummary(): string { - return $this->updateAt; + return $this->summary; } - public function setUpdateAt(?\DateTimeInterface $updateAt): static + public function setSummary(string $summary): static { - $this->updateAt = $updateAt; + $this->summary = $summary; return $this; } - public function getStatus(): ?int + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getNature(): string + { + return $this->nature; + } + + public function setNature(string $nature): static + { + $this->nature = $nature; + + return $this; + } + + public function getStatus(): string { return $this->status; } - public function setStatus(int $status): static + public function setStatus(string $status): static { $this->status = $status; return $this; } + public function getDueDate(): ?\DateTimeInterface + { + return $this->dueDate; + } + + public function setDueDate(?\DateTimeInterface $dueDate): static + { + $this->dueDate = $dueDate; + + return $this; + } + /** * @return Collection */ @@ -102,4 +165,9 @@ class Project return $this; } + + public function getTimelines(): Collection + { + return $this->timelines; + } } diff --git a/src/Entity/ProjectTimeline.php b/src/Entity/ProjectTimeline.php new file mode 100644 index 0000000..9006f50 --- /dev/null +++ b/src/Entity/ProjectTimeline.php @@ -0,0 +1,86 @@ +id; + } + + public function getProject(): Project + { + return $this->project; + } + + public function setProject(Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getUser(): User + { + return $this->user; + } + + public function setUser(User $user): static + { + $this->user = $user; + + return $this; + } + + public function getCreatedAt(): \DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createAt): static + { + $this->createdAt = $createAt; + + return $this; + } + + public function getDescription(): array + { + return $this->description; + } + + public function setDescription(array $description): static + { + $this->description = $description; + + return $this; + } +} diff --git a/src/EventListener/ProjectListener.php b/src/EventListener/ProjectListener.php new file mode 100644 index 0000000..9f01f5d --- /dev/null +++ b/src/EventListener/ProjectListener.php @@ -0,0 +1,155 @@ +getObjectManager(); + if (!$em instanceof EntityManagerInterface) { + return; + } + $uow = $em->getUnitOfWork(); + + $user = $this->security->getUser(); + if (!$user) { + return; + } + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!$entity instanceof Project) { + continue; + } + + $timeline = new ProjectTimeline(); + $timeline->setProject($entity); + $timeline->setUser($user); + $timeline->setCreatedAt(new \DateTime()); + $timeline->setDescription(['created' => true]); + + $em->persist($timeline); + $meta = $em->getClassMetadata(ProjectTimeline::class); + $uow->computeChangeSet($meta, $timeline); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!$entity instanceof Project) { + continue; + } + + $changeSet = $uow->getEntityChangeSet($entity); + $changes = []; + + foreach ($changeSet as $field => [$old, $new]) { + $oldStr = $this->stringify($old); + $newStr = $this->stringify($new); + + if ($oldStr !== $newStr) { + $changes[$field] = [ + 'old' => $oldStr, + 'new' => $newStr, + ]; + } + } + + if (!empty($changes)) { + $timeline = new ProjectTimeline(); + $timeline->setProject($entity); + $timeline->setUser($user); + $timeline->setCreatedAt(new \DateTime()); + $timeline->setDescription($changes); + + $em->persist($timeline); + $meta = $em->getClassMetadata(ProjectTimeline::class); + $uow->computeChangeSet($meta, $timeline); + } + } + + foreach ($uow->getScheduledCollectionUpdates() as $col) { + $owner = $col->getOwner(); + + if (!$owner instanceof Project) { + continue; + } + + $mapping = $col->getMapping(); + $fieldName = $mapping['fieldName']; + + $added = $col->getInsertDiff(); + $removed = $col->getDeleteDiff(); + + $changes = []; + + foreach ($added as $addedEntity) { + $changes[$fieldName.'_added'][] = $this->stringify($addedEntity); + } + + foreach ($removed as $removedEntity) { + $changes[$fieldName.'_removed'][] = $this->stringify($removedEntity); + } + + if (!empty($changes)) { + $timeline = new ProjectTimeline(); + $timeline->setProject($owner); + $timeline->setUser($user); + $timeline->setCreatedAt(new \DateTime()); + $timeline->setDescription($changes); + + $em->persist($timeline); + $meta = $em->getClassMetadata(ProjectTimeline::class); + $uow->computeChangeSet($meta, $timeline); + } + } + } + + private function stringify(mixed $value): string + { + if (is_null($value)) { + return 'null'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if ($value instanceof Collection || is_array($value)) { + $elements = []; + foreach ($value as $item) { + $elements[] = $this->stringify($item); + } + + return '['.implode(', ', $elements).']'; + } + + if (is_object($value)) { + if (method_exists($value, '__toString')) { + return (string) $value; + } + + foreach (['getUsername', 'getName', 'getTitle', 'getEmail', 'getId'] as $method) { + if (method_exists($value, $method)) { + return (string) $value->{$method}(); + } + } + + return get_class($value); + } + + return json_encode($value); + } +} diff --git a/src/Form/ProjectType.php b/src/Form/ProjectType.php index 376512a..5acf4ea 100644 --- a/src/Form/ProjectType.php +++ b/src/Form/ProjectType.php @@ -4,10 +4,12 @@ namespace App\Form; use App\Entity\Project; use App\Entity\User; +use Bnine\MdEditorBundle\Form\Type\MarkdownType; 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\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -26,8 +28,32 @@ class ProjectType extends AbstractType 'label' => 'Titre', ]) + ->add('summary', TextareaType::class, [ + 'label' => 'Résumé', + ]) + ->add('status', ChoiceType::class, [ - 'choices' => ['Brouillon' => 0], + 'label' => 'Statut', + 'choices' => [ + Project::DRAFT => Project::DRAFT, + Project::TOVOTE => Project::TOVOTE, + Project::VOTED => Project::VOTED, + Project::ARCHIVED => Project::ARCHIVED, + ], + ]) + + ->add('nature', ChoiceType::class, [ + 'label' => 'Nature', + 'choices' => [ + Project::NATURE_COLLECTIVE => Project::NATURE_COLLECTIVE, + Project::NATURE_STRATEGIC => Project::NATURE_STRATEGIC, + Project::NATURE_TACTICAL => Project::NATURE_TACTICAL, + ], + ]) + + ->add('description', MarkdownType::class, [ + 'label' => 'Description du Projet', + 'markdown_height' => 900, ]) ->add('users', EntityType::class, [ diff --git a/src/Repository/ProjectTimelineRepository.php b/src/Repository/ProjectTimelineRepository.php new file mode 100644 index 0000000..9e91712 --- /dev/null +++ b/src/Repository/ProjectTimelineRepository.php @@ -0,0 +1,18 @@ + + */ +class ProjectTimelineRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ProjectTimeline::class); + } +} diff --git a/src/Security/ProjectVoter.php b/src/Security/ProjectVoter.php new file mode 100644 index 0000000..b8012a4 --- /dev/null +++ b/src/Security/ProjectVoter.php @@ -0,0 +1,60 @@ +hasRole('ROLE_ADMIN') || (Project::DRAFT === $project->getStatus() && $project->getUsers()->contains($user)); + } + + private function canDelete(Project $project, User $user): bool + { + return $user->hasRole('ROLE_ADMIN') || (Project::DRAFT === $project->getStatus() && $project->getUsers()->contains($user)); + } + + private function canMoveDraft(Project $project, User $user): bool + { + return $user->hasRole('ROLE_ADMIN') || (Project::TOVOTE === $project->getStatus() && $project->getUsers()->contains($user)); + } + + protected function voteOnAttribute(string $attribute, $project, TokenInterface $token): bool + { + $user = $token->getUser(); + if (!$user instanceof User) { + return false; + } + + return match ($attribute) { + self::VIEW => $this->canView($project, $user), + self::EDIT => $this->canEdit($project, $user), + self::DELETE => $this->canDelete($project, $user), + self::MOVEDRAFT => $this->canMoveDraft($project, $user), + default => false, + }; + } +} diff --git a/symfony.lock b/symfony.lock index c102a36..d2e316b 100644 --- a/symfony.lock +++ b/symfony.lock @@ -6,7 +6,7 @@ "version": "v1.0.4" }, "bnine/mdeditorbundle": { - "version": "v0.1.1" + "version": "v1.1.1" }, "doctrine/deprecations": { "version": "1.1", diff --git a/templates/base.html.twig b/templates/base.html.twig index 91c467f..3696364 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -20,7 +20,7 @@ - + @@ -29,7 +29,8 @@ - + + {% block javascripts %} diff --git a/templates/home/home.html.twig b/templates/home/home.html.twig index c8e325f..c7f1c06 100644 --- a/templates/home/home.html.twig +++ b/templates/home/home.html.twig @@ -1,12 +1,25 @@ {% extends 'base.html.twig' %} + + + {%block body%}

Projets

- -
+
{% for project in projects %} -
-
{{project.title}}
- {{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:0})) }} +
+
+ {{project.title}} + +
+ {% if is_granted('EDIT', project) %} + + {% endif %} + + +
+
+
+
{% endfor %} diff --git a/templates/project/edit.html.twig b/templates/project/edit.html.twig index 6f290a4..f8b3d2a 100644 --- a/templates/project/edit.html.twig +++ b/templates/project/edit.html.twig @@ -14,31 +14,40 @@ {% include('include/error.html.twig') %}
-
+
Information
{{ form_row(form.title) }} + {{ form_row(form.nature) }} {{ form_row(form.status) }} + {{ form_row(form.users) }} + {{ form_row(form.summary) }}
+ + {{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:1})) }} + +
+
Timeline
+
+ {% include('project/timeline.html.twig') %} +
+ +
+
-
+
-
Permissions
+
Description du Projet
- {{ form_row(form.users) }} + {{ form_widget(form.description) }}
- - {% if mode=="update" %} - {{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:1})) }} - {% endif %} {{ form_end(form) }} - {% endblock %} {% block localscript %} diff --git a/templates/project/list.html.twig b/templates/project/list.html.twig index 51563ec..6442c21 100644 --- a/templates/project/list.html.twig +++ b/templates/project/list.html.twig @@ -22,7 +22,7 @@ {{project.title}} - {{project.updateAt}} + {{project.timelines.first ? project.timelines.first.createdAt|date('d/m/Y H:i') : '' }} {% endfor %} diff --git a/templates/project/timeline.html.twig b/templates/project/timeline.html.twig new file mode 100644 index 0000000..dd61a54 --- /dev/null +++ b/templates/project/timeline.html.twig @@ -0,0 +1,57 @@ + + +
+ {% for event in project.timelines %} +
+
+
+

+ {{ event.createdAt|date('d/m/Y H:i') }} par {{ event.user.username }}
+ {% for field, change in event.description %} + {{ field }} + {% if field=="status" %} + {{ change.old ?? '' }} + → + {{ change.new ?? '' }} + {% endif %} + + {% if not change.old is defined %} + = {{ change[0] }} + {% endif %} +
+ {% endfor %} +

+
+
+ {% endfor %} +
+ +