This commit is contained in:
2025-08-03 22:42:14 +02:00
parent d7a36aca4c
commit 67767310d3
16 changed files with 532 additions and 45 deletions

View File

@ -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",

17
composer.lock generated
View File

@ -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",

View File

@ -29,4 +29,3 @@ services:
App\Security\DynamicAuthenticator:
arguments:
$modeAuth: '%env(MODE_AUTH)%'

View File

@ -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', [

View File

@ -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<int, User>
@ -30,9 +49,17 @@ class Project
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'projects')]
private Collection $users;
/**
* @var Collection<int, ProjectTimeline>
*/
#[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<int, User>
*/
@ -102,4 +165,9 @@ class Project
return $this;
}
public function getTimelines(): Collection
{
return $this->timelines;
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Entity;
use App\Repository\ProjectTimelineRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProjectTimelineRepository::class)]
class ProjectTimeline
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'timelines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Project $project;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $createdAt;
#[ORM\Column(type: 'array')]
private array $description;
public function __construct()
{
}
public function getId(): ?int
{
return $this->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;
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\EventListener;
use App\Entity\Project;
use App\Entity\ProjectTimeline;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Symfony\Bundle\SecurityBundle\Security;
#[AsDoctrineListener(event: 'onFlush')]
class ProjectListener
{
public function __construct(
private readonly Security $security,
) {
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->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);
}
}

View File

@ -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, [

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\ProjectTimeline;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProjectTimeline>
*/
class ProjectTimelineRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProjectTimeline::class);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Security;
use App\Entity\Project;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProjectVoter extends Voter
{
// Les actions que ce voter supporte
private const EDIT = 'EDIT';
private const VIEW = 'VIEW';
private const DELETE = 'DELETE';
private const MOVEDRAFT = 'MOVEDRAFT';
protected function supports(string $attribute, $subject): bool
{
$attributes = [self::EDIT, self::VIEW, self::DELETE, self::MOVEDRAFT];
return in_array($attribute, $attributes) && $subject instanceof Project;
}
private function canView(Project $project, User $user): bool
{
return true;
}
private function canEdit(Project $project, User $user): bool
{
return $user->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,
};
}
}

View File

@ -6,7 +6,7 @@
"version": "v1.0.4"
},
"bnine/mdeditorbundle": {
"version": "v0.1.1"
"version": "v1.1.1"
},
"doctrine/deprecations": {
"version": "1.1",

View File

@ -20,7 +20,7 @@
<script src="{{ asset('lib/jquery/jquery.min.js') }}"></script>
<script src="{{ asset('lib/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ asset('lib/fontawesome/fontawesome-free.index.js') }}"></script>
<!-- <script src="{{ asset('lib/fontawesome/fontawesome-free.index.js') }}"></script> -->
<script src="{{ asset('lib/datatables/datatables.min.js') }}"></script>
<script src="{{ asset('lib/datatables/datatables.init.js') }}"></script>
<script src="{{ asset('lib/select2/select2.min.js') }}"></script>
@ -29,7 +29,8 @@
<script src="{{ asset('lib/imgareaselect/js/jquery.imgareaselect.dev.js') }}"></script>
<script src="{{ asset('lib/jqueryui/jquery-ui.min.js') }}"></script>
<script src="{{ asset('lib/chart/chart.js') }}"></script>
<script src="{{ asset('bundles/bninemdeditor/bninemdeditor.js') }}"></script>
<script src="{{ asset('lib/app/app.js') }}"></script>
{% block javascripts %}

View File

@ -1,12 +1,25 @@
{% extends 'base.html.twig' %}
{%block body%}
<h2>Projets</h2>
<div class='d-flex' style='justify-content: center'>
<div class='d-flex' style='justify-content: left'>
{% for project in projects %}
<div class='card'>
<h5>{{project.title}}</h5>
{{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:0})) }}
<div class='card' style='width:300px'>
<div class='card-header d-flex justify-content-between align-items-center'>
{{project.title}}
<div>
{% if is_granted('EDIT', project) %}
<button type="button" class="btn btn-primary btn-sm"><i class="fas fa-pencil"></i></button>
{% endif %}
<button type="button" class="btn btn-secondary btn-sm"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class='card-body'>
</div>
</div>
{% endfor %}

View File

@ -14,31 +14,40 @@
{% include('include/error.html.twig') %}
<div class="row">
<div class="col-md-6 mx-auto">
<div class="col-md-4 mx-auto">
<div class="card mt-3">
<div class="card-header">Information</div>
<div class="card-body">
{{ form_row(form.title) }}
{{ form_row(form.nature) }}
{{ form_row(form.status) }}
{{ form_row(form.users) }}
{{ form_row(form.summary) }}
</div>
</div>
{{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:1})) }}
<div class="card mt-3">
<div class="card-header">Timeline</div>
<div class="card-body">
{% include('project/timeline.html.twig') %}
</div>
</div>
</div>
<div class="col-md-6 mx-auto">
<div class="col-md-8 mx-auto">
<div class="card mt-3">
<div class="card-header">Permissions</div>
<div class="card-header">Description du Projet</div>
<div class="card-body">
{{ form_row(form.users) }}
{{ form_widget(form.description) }}
</div>
</div>
</div>
</div>
{% if mode=="update" %}
{{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:1})) }}
{% endif %}
{{ form_end(form) }}
{% endblock %}
{% block localscript %}

View File

@ -22,7 +22,7 @@
<a href="{{ path(routeupdate,{id:project.id}) }}" class="me-2"><i class="fas fa-file fa-2x"></i></a>
</td>
<td>{{project.title}}</td>
<td>{{project.updateAt}}</td>
<td>{{project.timelines.first ? project.timelines.first.createdAt|date('d/m/Y H:i') : '' }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,57 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<div class="timeline">
{% for event in project.timelines %}
<div class="timeline-item">
<div class="timeline-marker"></div>
<div class="timeline-content">
<p class="text-muted small mb-1">
{{ event.createdAt|date('d/m/Y H:i') }} par <strong>{{ event.user.username }}</strong><br>
{% for field, change in event.description %}
<strong>{{ field }}</strong>
{% if field=="status" %}
<span class="text-danger">{{ change.old ?? '' }}</span>
<span class="text-success">{{ change.new ?? '' }}</span>
{% endif %}
{% if not change.old is defined %}
= <span>{{ change[0] }}</span>
{% endif %}
<br>
{% endfor %}
</p>
</div>
</div>
{% endfor %}
</div>
<style>
.timeline {
border-left: 3px solid #dee2e6;
padding-left: 2rem;
position: relative;
font-size: 14px;
}
.timeline-item {
position: relative;
margin-bottom: 10px;
margin-left: -23px;
}
.timeline-content {
line-height:14px;
}
.timeline-marker {
position: absolute;
left: -1.1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: #0d6efd;
border: 2px solid #fff;
box-shadow: 0 0 0 3px #0d6efd44;
}
.timeline-content {
padding-left: 1rem;
}
</style>