Compare commits

...

6 Commits

Author SHA1 Message Date
589a2eacb9 svg 2025-09-18 22:01:50 +02:00
bcdf788be3 svg 2025-09-18 11:40:18 +02:00
92976d8496 svg 2025-09-17 16:42:38 +02:00
3ad0b8f15e srcf 2025-09-17 16:20:51 +02:00
91022d2037 srcf 2025-09-17 14:36:05 +02:00
b7b07e5abf svg 2025-09-17 09:00:54 +02:00
35 changed files with 1453 additions and 187 deletions

View File

@@ -7,8 +7,7 @@ node_modules/
.env.*.local
# Cache et logs Symfony
var/cache/
var/log/
var
# Build front-end
public/build/

View File

@@ -40,6 +40,7 @@
"symfony/property-info": "^7.2",
"symfony/runtime": "^7.2",
"symfony/security-bundle": "^7.2",
"symfony/security-csrf": "^7.2",
"symfony/serializer": "^7.2",
"symfony/stimulus-bundle": "^2.21",
"symfony/string": "^7.2",

2
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": "67e324518270930150d990299cd0b613",
"content-hash": "31c3ee9a06365c5a9df6f2ed45712eb3",
"packages": [
{
"name": "apereo/phpcas",

View File

@@ -1,10 +1,14 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
csrf_protection: true
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
enabled: true
handler_id: null
cookie_secure: auto
#esi: true
#fragments: true

View File

@@ -22,7 +22,9 @@ security:
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
enable_csrf: false
csrf_token_id: authenticate
csrf_parameter: _csrf_token
default_target_path: /
logout:
path: app_logout

View File

@@ -11,9 +11,10 @@ COPY ./misc/docker/apache.conf /etc/apache2/conf.d/nine/site.conf
WORKDIR /app
# Crée vendor à lavance et donne les droits
RUN mkdir -p /app/vendor && chown -R apache:apache /app
RUN mkdir -p /app/vendor && mkdir -p /app/var && chown -R apache:apache /app
USER apache
WORKDIR /app
COPY --chown=apache:apache . .
RUN composer install --no-interaction

View File

@@ -7,6 +7,8 @@ cd ${DIR}
cd ../..
DIR=$(pwd)
bin/console cache:clear --env=dev
bin/console cache:clear --env=prod
bin/console d:s:u --force --complete
bin/console app:init

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Controller;
use App\Entity\Group;
use App\Form\GroupType;
use App\Repository\GroupRepository;
use App\Security\GroupVoter;
use Bnine\FilesBundle\Service\FileService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
class GroupController extends AbstractController
{
private FileService $fileService;
public function __construct(FileService $fileService)
{
$this->fileService = $fileService;
}
#[Route('/admin/group', name: 'app_admin_group')]
public function list(GroupRepository $groupRepository): Response
{
$groups = $groupRepository->findAll();
return $this->render('group/list.html.twig', [
'usemenu' => true,
'usesidebar' => true,
'title' => 'Liste des Groupes',
'routesubmit' => 'app_admin_group_submit',
'routeupdate' => 'app_admin_group_update',
'groups' => $groups,
]);
}
#[Route('/admin/group/submit', name: 'app_admin_group_submit')]
#[Route('/user/group/submit', name: 'app_user_group_submit')]
public function submit(Request $request, EntityManagerInterface $em): Response
{
$group = new Group();
$group->addUser($this->getUser());
$group->setStatus(Group::ACTIVE);
$group->setOpen(true);
$this->denyAccessUnlessGranted(GroupVoter::SUBMIT, $group);
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
$form = $this->createForm(GroupType::class, $group, ['mode' => 'submit']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($group);
$em->flush();
$this->fileService->init('group', $group->getId());
return $this->redirectToRoute($isAdmin ? 'app_admin_group_update' : 'app_user_group_update', ['id' => $group->getId()]);
}
return $this->render('group/edit.html.twig', [
'usemenu' => true,
'usesidebar' => $isAdmin,
'title' => 'Création Groupe',
'routecancel' => $isAdmin ? 'app_admin_group' : 'app_home',
'routedelete' => $isAdmin ? 'app_admin_group_delete' : 'app_user_group_delete',
'mode' => 'submit',
'form' => $form,
]);
}
#[Route('/admin/group/update/{id}', name: 'app_admin_group_update')]
#[Route('/user/group/update/{id}', name: 'app_user_group_update')]
public function update(int $id, Request $request, GroupRepository $groupRepository, EntityManagerInterface $em): Response
{
$group = $groupRepository->find($id);
if (!$group) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$this->denyAccessUnlessGranted(GroupVoter::UPDATE, $group);
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
$form = $this->createForm(GroupType::class, $group, ['mode' => 'update']);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
if ($isAdmin) {
return $this->redirectToRoute('app_admin_group');
} else {
return $this->redirectToRoute('app_user_group_view', ['id' => $group->getId()]);
}
}
return $this->render('group/edit.html.twig', [
'usemenu' => true,
'usesidebar' => $isAdmin,
'title' => 'Modification Groupe = '.$group->getTitle(),
'routecancel' => $isAdmin ? 'app_admin_group' : 'app_home',
'routemove' => $isAdmin ? 'app_admin_group_move' : 'app_user_group_move',
'routedelete' => $isAdmin ? 'app_admin_group_delete' : 'app_user_group_delete',
'routesubmitoption' => $isAdmin ? 'app_admin_option_submit' : 'app_user_option_submit',
'routeupdateoption' => $isAdmin ? 'app_admin_option_update' : 'app_user_option_update',
'mode' => 'update',
'form' => $form,
'group' => $group,
]);
}
#[Route('/admin/group/moveto/{id}/{status}', name: 'app_admin_group_move')]
#[Route('/user/group/moveto/{id}/{status}', name: 'app_user_group_move')]
public function move(int $id, string $status, Request $request, GroupRepository $groupRepository, EntityManagerInterface $em): Response
{
$group = $groupRepository->find($id);
if (!$group) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$attribute = constant(GroupVoter::class.'::MOVETO'.$status);
$this->denyAccessUnlessGranted($attribute, $group);
$group->setStatus(constant(Group::class.'::'.$status));
$em->flush();
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
return $this->redirectToRoute($isAdmin ? 'app_admin_group' : 'app_user_group_view', ['id' => $group->getId()]);
}
#[Route('/admin/group/subscribe/{id}', name: 'app_admin_group_subscribe')]
#[Route('/user/group/subscribe/{id}', name: 'app_user_group_subscribe')]
public function subscribe(int $id, Request $request, GroupRepository $groupRepository, EntityManagerInterface $em): Response
{
$group = $groupRepository->find($id);
if (!$group) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$this->denyAccessUnlessGranted(GroupVoter::CANSUBSCRIBE, $group);
$group->addUser($this->getUser());
$em->flush();
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
return $this->redirectToRoute($isAdmin ? 'app_admin_group' : 'app_user_group_view', ['id' => $group->getId()]);
}
#[Route('/admin/group/delete/{id}', name: 'app_admin_group_delete')]
#[Route('/user/group/delete/{id}', name: 'app_user_group_delete')]
public function delete(int $id, Request $request, GroupRepository $groupRepository, EntityManagerInterface $em): Response
{
$group = $groupRepository->find($id);
if (!$group) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$this->denyAccessUnlessGranted(GroupVoter::DELETE, $group);
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
// Tentative de suppression
try {
$em->remove($group);
$em->flush();
} catch (\Exception $e) {
$this->addflash('error', $e->getMessage());
}
return $this->redirectToRoute($isAdmin ? 'app_admin_group' : 'app_home');
}
#[Route('/user/group/view/{id}', name: 'app_user_group_view')]
public function view(int $id, Request $request, GroupRepository $groupRepository, EntityManagerInterface $em): Response
{
$group = $groupRepository->find($id);
if (!$group) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$this->denyAccessUnlessGranted(GroupVoter::VIEW, $group);
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
return $this->render('group/view.html.twig', [
'usemenu' => true,
'usesidebar' => false,
'title' => 'Group = '.$group->getTitle(),
'routeupdate' => $isAdmin ? 'app_admin_group_update' : 'app_user_group_update',
'routecancel' => $isAdmin ? 'app_admin_group' : 'app_home',
'routedelete' => $isAdmin ? 'app_admin_group_delete' : 'app_user_group_delete',
'routemove' => $isAdmin ? 'app_admin_group_move' : 'app_user_group_move',
'routesubscribe' => $isAdmin ? 'app_admin_group_subscribe' : 'app_user_group_subscribe',
'group' => $group,
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Repository\GroupRepository;
use App\Repository\ProjectRepository;
use App\Service\EtherpadService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -11,14 +12,16 @@ use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function home(ProjectRepository $projectRepository): Response
public function home(ProjectRepository $projectRepository, GroupRepository $groupRepository): Response
{
$projects = $projectRepository->findAll();
$groups = $groupRepository->findAll();
return $this->render('home/home.html.twig', [
'usemenu' => true,
'usesidebar' => false,
'projects' => $projects,
'groups' => $groups,
]);
}
@@ -35,14 +38,7 @@ class HomeController extends AbstractController
public function etherpad(string $id, EtherpadService $etherpadService): Response
{
$padAccess = $etherpadService->preparePadAccess($this->getUser(), $id);
dump(vars: $padAccess);
$response = $this->render('etherpad/show.html.twig', [
'iframeUrl' => $padAccess['iframeUrl'],
]);
$response->headers->setCookie($padAccess['cookie']);
return $response;
return $this->redirect($padAccess['iframeUrl']);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Entity\Project;
use App\Entity\ProjectOption;
use App\Form\ProjectType;
use App\Form\ProjectVotedType;
use App\Repository\ProjectRepository;
@@ -46,6 +47,7 @@ class ProjectController extends AbstractController
$project = new Project();
$project->addUser($this->getUser());
$project->setStatus(Project::DRAFT);
$project->setOpen(true);
$this->denyAccessUnlessGranted(ProjectVoter::SUBMIT, $project);
@@ -55,6 +57,20 @@ class ProjectController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($project);
$em->flush();
// Création d'option par défaut
$option = new ProjectOption();
$option->setProject($project);
$option->setTitle('Pour');
$em->persist($option);
$em->flush();
$option = new ProjectOption();
$option->setProject($project);
$option->setTitle('Contre');
$em->persist($option);
$em->flush();
$this->fileService->init('project', $project->getId());
return $this->redirectToRoute($isAdmin ? 'app_admin_project_update' : 'app_user_project_update', ['id' => $project->getId()]);
@@ -146,6 +162,25 @@ class ProjectController extends AbstractController
return $this->redirectToRoute($isAdmin ? 'app_admin_project' : 'app_user_project_view', ['id' => $project->getId()]);
}
#[Route('/admin/project/subscribe/{id}', name: 'app_admin_project_subscribe')]
#[Route('/user/project/subscribe/{id}', name: 'app_user_project_subscribe')]
public function subscribe(int $id, Request $request, ProjectRepository $projectRepository, EntityManagerInterface $em): Response
{
$project = $projectRepository->find($id);
if (!$project) {
throw new NotFoundHttpException('La ressource demandée est introuvable.');
}
$this->denyAccessUnlessGranted(ProjectVoter::CANSUBSCRIBE, $project);
$project->addUser($this->getUser());
$em->flush();
$isAdmin = str_starts_with($request->attributes->get('_route'), 'app_admin');
return $this->redirectToRoute($isAdmin ? 'app_admin_project' : 'app_user_project_view', ['id' => $project->getId()]);
}
#[Route('/admin/project/delete/{id}', name: 'app_admin_project_delete')]
#[Route('/user/project/delete/{id}', name: 'app_user_project_delete')]
public function delete(int $id, Request $request, ProjectRepository $projectRepository, EntityManagerInterface $em): Response
@@ -190,6 +225,7 @@ class ProjectController extends AbstractController
'routecancel' => $isAdmin ? 'app_admin_project' : 'app_home',
'routedelete' => $isAdmin ? 'app_admin_project_delete' : 'app_user_project_delete',
'routemove' => $isAdmin ? 'app_admin_project_move' : 'app_user_project_move',
'routesubscribe' => $isAdmin ? 'app_admin_project_subscribe' : 'app_user_project_subscribe',
'project' => $project,
]);
}

View File

@@ -2,14 +2,12 @@
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Form\UserType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -162,30 +160,4 @@ class UserController extends AbstractController
'form' => $form,
]);
}
#[Route('/user/selectproject', name: 'app_user_selectproject')]
public function selectproject(Request $request, EntityManagerInterface $em): JsonResponse
{
$id = $request->get('id');
$project = $em->getRepository(Project::class)->find($id);
if (!$project) {
return new JsonResponse(['status' => 'KO', 'message' => 'ID non fourni'], Response::HTTP_NOT_FOUND);
}
$user = $this->getUser();
if (!$user instanceof User) {
throw new \LogicException('L\'utilisateur actuel n\'est pas une instance de App\Entity\User.');
}
$projects = $user->getProjects();
if (!$projects->contains($project)) {
return new JsonResponse(['status' => 'KO', 'message' => 'Projet non autorisée'], Response::HTTP_FORBIDDEN);
}
$user->setProject($project);
$em->flush();
return new JsonResponse(['status' => 'OK', 'message' => 'Projet selectionnée'], Response::HTTP_OK);
}
}

165
src/Entity/Group.php Normal file
View File

@@ -0,0 +1,165 @@
<?php
namespace App\Entity;
use App\Repository\GroupRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: GroupRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'groupe')]
class Group
{
public const ACTIVE = 'Actif';
public const INACTIVE = 'Inactif';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
private string $summary;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'boolean', nullable: false)]
private bool $open;
#[ORM\Column]
private string $status;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $dueDate = null;
/**
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'groups')]
private Collection $users;
/**
* @var Collection<int, GroupTimeline>
*/
#[ORM\OneToMany(mappedBy: 'group', targetEntity: GroupTimeline::class, cascade: ['remove'], orphanRemoval: true)]
#[ORM\OrderBy(['createdAt' => 'DESC'])]
private Collection $timelines;
public function __construct()
{
$this->users = new ArrayCollection();
$this->timelines = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getSummary(): string
{
return $this->summary;
}
public function setSummary(string $summary): static
{
$this->summary = $summary;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function isOpen(): bool
{
return $this->open;
}
public function setOpen(bool $open): static
{
$this->open = $open;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
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;
}
public function getUsers(): Collection
{
return $this->users;
}
public function addUser(User $user): static
{
if (!$this->users->contains($user)) {
$this->users->add($user);
$user->addGroup($this);
}
return $this;
}
public function removeUser(User $user): static
{
if ($this->users->removeElement($user)) {
$user->removeGroup($this);
}
return $this;
}
public function getTimelines(): Collection
{
return $this->timelines;
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Entity;
use App\Repository\GroupTimelineRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: GroupTimelineRepository::class)]
class GroupTimeline
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'timelines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Group $group;
#[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 getGroup(): Group
{
return $this->group;
}
public function setGroup(Group $group): static
{
$this->group = $group;
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

@@ -36,6 +36,9 @@ class Project
#[ORM\Column(type: 'string', length: 20)]
private string $nature;
#[ORM\Column(type: 'boolean', nullable: false)]
private bool $open;
#[ORM\Column]
private string $status;
@@ -131,6 +134,18 @@ class Project
return $this;
}
public function isOpen(): bool
{
return $this->open;
}
public function setOpen(bool $open): static
{
$this->open = $open;
return $this;
}
public function getStatus(): string
{
return $this->status;

View File

@@ -45,13 +45,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\ManyToMany(targetEntity: Project::class, inversedBy: 'users')]
private ?Collection $projects;
#[ORM\ManyToOne()]
#[ORM\JoinColumn(nullable: true)]
private ?Project $project = null;
#[ORM\ManyToMany(targetEntity: Group::class, inversedBy: 'users')]
private ?Collection $groups;
public function __construct()
{
$this->projects = new ArrayCollection();
$this->groups = new ArrayCollection();
}
public function getId(): ?int
@@ -71,21 +71,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->username;
}
/**
* @see UserInterface
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = $this->roles;
@@ -95,9 +85,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return array_unique($roles);
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): static
{
$this->roles = $roles;
@@ -125,9 +112,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
@@ -194,18 +178,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getProject(): ?Project
/**
* @return Collection<int, Group>
*/
public function getGroups(): ?Collection
{
if (!$this->projects) {
return null;
return $this->groups;
}
return $this->project;
public function addGroup(Group $group): static
{
if (!$this->groups->contains($group)) {
$this->groups->add($group);
}
public function setProject(?Project $project): static
return $this;
}
public function removeGroup(Group $group): static
{
$this->project = $project;
$this->groups->removeElement($group);
return $this;
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\EventListener;
use App\Entity\Group;
use App\Entity\GroupTimeline;
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 GroupListener
{
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 Group) {
continue;
}
$timeline = new GroupTimeline();
$timeline->setGroup($entity);
$timeline->setUser($user);
$timeline->setCreatedAt(new \DateTime());
$timeline->setDescription(['created' => true]);
$em->persist($timeline);
$meta = $em->getClassMetadata(GroupTimeline::class);
$uow->computeChangeSet($meta, $timeline);
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Group) {
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 GroupTimeline();
$timeline->setGroup($entity);
$timeline->setUser($user);
$timeline->setCreatedAt(new \DateTime());
$timeline->setDescription($changes);
$em->persist($timeline);
$meta = $em->getClassMetadata(GroupTimeline::class);
$uow->computeChangeSet($meta, $timeline);
}
}
foreach ($uow->getScheduledCollectionUpdates() as $col) {
$owner = $col->getOwner();
if (!$owner instanceof Group) {
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 GroupTimeline();
$timeline->setGroup($owner);
$timeline->setUser($user);
$timeline->setCreatedAt(new \DateTime());
$timeline->setDescription($changes);
$em->persist($timeline);
$meta = $em->getClassMetadata(GroupTimeline::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);
}
}

76
src/Form/GroupType.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace App\Form;
use App\Entity\Group;
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\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
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;
class GroupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('submit', SubmitType::class, [
'label' => 'Valider',
'attr' => ['class' => 'btn btn-success no-print me-1'],
])
->add('title', TextType::class, [
'label' => 'Titre',
])
->add('dueDate', DateType::class, [
'label' => 'A Voter pour le',
'required' => false,
'html5' => true,
])
->add('summary', TextareaType::class, [
'label' => 'Résumé',
])
->add('open', CheckboxType::class, [
'label' => 'Groupe Ouvert',
'required' => false,
])
->add('users', EntityType::class, [
'label' => 'Paticipants',
'class' => User::class,
'choice_label' => 'username',
'multiple' => true,
'attr' => ['class' => 'select2'],
'required' => false,
'by_reference' => false,
]);
if ('update' == $options['mode']) {
$builder
->add('description', MarkdownType::class, [
'label' => 'Description du Projet',
'markdown_height' => 900,
]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Group::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => static::class,
'mode' => 'submit',
]);
}
}

View File

@@ -39,6 +39,9 @@ class OptionType extends AbstractType
{
$resolver->setDefaults([
'data_class' => ProjectOption::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => static::class,
'mode' => 'submit',
]);
}

View File

@@ -7,6 +7,7 @@ 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\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -39,6 +40,11 @@ class ProjectType extends AbstractType
'label' => 'Résumé',
])
->add('open', CheckboxType::class, [
'label' => 'Projet Ouvert',
'required' => false,
])
->add('nature', ChoiceType::class, [
'label' => 'Nature',
'choices' => [
@@ -49,7 +55,7 @@ class ProjectType extends AbstractType
])
->add('users', EntityType::class, [
'label' => 'Propriétaires',
'label' => 'Paticipants',
'class' => User::class,
'choice_label' => 'username',
'multiple' => true,
@@ -71,6 +77,9 @@ class ProjectType extends AbstractType
{
$resolver->setDefaults([
'data_class' => Project::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => static::class,
'mode' => 'submit',
]);
}

View File

@@ -54,6 +54,9 @@ class ProjectVotedType extends AbstractType
{
$resolver->setDefaults([
'data_class' => Project::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => static::class,
'mode' => 'submit',
]);
}

View File

@@ -31,11 +31,6 @@ class UserType extends AbstractType
'label' => 'Login',
])
->add('apikey', TextType::class, [
'label' => 'apikey',
'required' => false,
])
->add('avatar', HiddenType::class)
->add('email', EmailType::class, [
@@ -56,6 +51,7 @@ class UserType extends AbstractType
'choice_label' => 'title',
'multiple' => true,
'attr' => ['class' => 'select2'],
'required' => false,
]);
}
@@ -81,6 +77,9 @@ class UserType extends AbstractType
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => static::class,
'mode' => 'submit',
'modeAuth' => 'SQL',
]);

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Security;
use App\Entity\User;
use App\Repository\GroupRepository;
use App\Repository\ProjectRepository;
use Bnine\FilesBundle\Security\AbstractFileVoter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -10,8 +11,9 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class FileVoter extends AbstractFileVoter
{
private ProjectRepository $projectRepository;
private GroupRepository $groupRepository;
public function __construct(ProjectRepository $projectRepository)
public function __construct(ProjectRepository $projectRepository, GroupRepository $groupRepository)
{
$this->projectRepository = $projectRepository;
}
@@ -43,6 +45,12 @@ class FileVoter extends AbstractFileVoter
return true;
}
break;
case 'group':
$group = $this->groupRepository->find($id);
if ($group && $group->getUsers()->contains($user)) {
return true;
}
break;
}
return false;
@@ -65,6 +73,12 @@ class FileVoter extends AbstractFileVoter
return true;
}
break;
case 'group':
$group = $this->groupRepository->find($id);
if ($group && $group->getUsers()->contains($user)) {
return true;
}
break;
}
return false;

105
src/Security/GroupVoter.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
namespace App\Security;
use App\Entity\Group;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class GroupVoter extends Voter
{
// Les actions que ce voter supporte
public const VIEW = 'VIEW';
public const SUBMIT = 'SUBMIT';
public const UPDATE = 'UPDATE';
public const DELETE = 'DELETE';
public const MOVETOACTIVE = 'MOVETOACTIVE';
public const MOVETOINACTIVE = 'MOVETOINACTIVE';
public const TOME = 'TOME';
public const CANSUBSCRIBE = 'CANSUBSCRIBE';
protected function supports(string $attribute, $subject): bool
{
$attributes = [self::VIEW, self::SUBMIT, self::UPDATE, self::DELETE, self::MOVETOACTIVE, self::MOVETOINACTIVE, self::TOME, self::CANSUBSCRIBE];
return in_array($attribute, $attributes) && $subject instanceof Group;
}
private function canView(Group $group, User $user): bool
{
return true;
}
private function canSubmit(Group $group, User $user): bool
{
return true;
}
private function canUpdate(Group $group, User $user): bool
{
$hasMaster = $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_MASTER');
$hasUser = $group->getUsers()->contains($user);
$hasStatus = Group::ACTIVE === $group->getStatus();
return $hasMaster || ($hasUser && $hasStatus);
}
private function canDelete(Group $group, User $user): bool
{
$hasMaster = $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_MASTER');
$hasUser = $group->getUsers()->contains($user);
$hasStatus = Group::ACTIVE === $group->getStatus();
return $hasMaster || ($hasUser && $hasStatus);
}
private function canMoveToInactive(Group $group, User $user): bool
{
$hasUser = $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_MASTER') || $group->getUsers()->contains($user);
$hasStatus = Group::ACTIVE === $group->getStatus();
dump($hasUser);
dump($hasStatus);
return $hasUser && $hasStatus;
}
private function canMoveToActive(Group $group, User $user): bool
{
$hasUser = $user->hasRole('ROLE_ADMIN') || $user->hasRole('ROLE_MASTER') || $group->getUsers()->contains($user);
$hasStatus = Group::INACTIVE === $group->getStatus();
return $hasUser && $hasStatus;
}
private function toMe(Group $group, User $user): bool
{
return $group->getUsers()->contains($user);
}
private function canSubscribe(Group $group, User $user): bool
{
return $group->isOpen() && !$group->getUsers()->contains($user);
}
protected function voteOnAttribute(string $attribute, $group, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW => $this->canView($group, $user),
self::SUBMIT => $this->canSubmit($group, $user),
self::UPDATE => $this->canUpdate($group, $user),
self::DELETE => $this->canDelete($group, $user),
self::MOVETOACTIVE => $this->canMoveToActive($group, $user),
self::MOVETOINACTIVE => $this->canMoveToInactive($group, $user),
self::TOME => $this->toMe($group, $user),
self::CANSUBSCRIBE => $this->canSubscribe($group, $user),
default => false,
};
}
}

View File

@@ -19,10 +19,11 @@ class ProjectVoter extends Voter
public const MOVEVOTED = 'MOVEVOTED';
public const MOVEARCHIVED = 'MOVEARCHIVED';
public const TOME = 'TOME';
public const CANSUBSCRIBE = 'CANSUBSCRIBE';
protected function supports(string $attribute, $subject): bool
{
$attributes = [self::VIEW, self::SUBMIT, self::UPDATE, self::DELETE, self::MOVEDRAFT, self::MOVETOVOTE, self::MOVEVOTED, self::MOVEARCHIVED, self::TOME];
$attributes = [self::VIEW, self::SUBMIT, self::UPDATE, self::DELETE, self::MOVEDRAFT, self::MOVETOVOTE, self::MOVEVOTED, self::MOVEARCHIVED, self::TOME, self::CANSUBSCRIBE];
return in_array($attribute, $attributes) && $subject instanceof Project;
}
@@ -97,6 +98,11 @@ class ProjectVoter extends Voter
return $project->getUsers()->contains($user);
}
private function canSubscribe(Project $project, User $user): bool
{
return $project->isOpen() && !$project->getUsers()->contains($user);
}
protected function voteOnAttribute(string $attribute, $project, TokenInterface $token): bool
{
$user = $token->getUser();
@@ -114,6 +120,7 @@ class ProjectVoter extends Voter
self::MOVEVOTED => $this->canMoveVoted($project, $user),
self::MOVEARCHIVED => $this->canMoveArchived($project, $user),
self::TOME => $this->toMe($project, $user),
self::CANSUBSCRIBE => $this->canSubscribe($project, $user),
default => false,
};
}

View File

@@ -0,0 +1,69 @@
{% extends 'base.html.twig' %}
{% block title %} = {{title}}{% endblock %}
{% block body %}
<h1>{{title}}</h1>
{{ form_start(form) }}
{{ form_widget(form.submit) }}
<a href="{{ path(routecancel) }}" class="btn btn-secondary me-5">Annuler</a>
{% if mode=="update" and is_granted('DELETE', group) %}
<a href="{{ path(routedelete,{id:group.id}) }}" class="btn btn-danger float-end" onclick="return confirm('Confirmez-vous la suppression de cet enregistrement ?')">Supprimer</a>
{% endif %}
{% include('include/error.html.twig') %}
<div class="row">
<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.open) }}
{{ form_row(form.dueDate) }}
{{ form_row(form.users) }}
{{ form_row(form.summary) }}
</div>
</div>
{% if mode=="update" %}
{{ render(path("bninefiles_files",{domain:'group',id:group.id, editable:1})) }}
<div class="card mt-3">
<div class="card-header">Timeline</div>
<div class="card-body">
{% include('group/timeline.html.twig') %}
</div>
</div>
{% endif %}
</div>
{% if mode=="update" %}
<div class="col-md-8 mx-auto">
<div class="card mt-3">
<div class="card-header">Pad du Groupe</div>
<div class="card-body">
{{ form_widget(form.description) }}
</div>
</div>
</div>
{% endif %}
</div>
{{ form_end(form) }}
{% endblock %}
{% block localscript %}
<script>
$(document).ready(function() {
$("#group_title").focus();
$('#dataTables').DataTable({
columnDefs: [ { "targets": "no-sort", "orderable": false }, { "targets": "no-string", "type" : "num" } ],
responsive: true,
iDisplayLength: 100,
order: [[ 1, "asc" ]]
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends 'base.html.twig' %}
{% block title %} = {{title}}{% endblock %}
{% block body %}
<h1>{{title}}</h1>
<a href="{{ path(routesubmit) }}" class="btn btn-success">Ajouter</a>
<div class="dataTable_wrapper">
<table class="table table-striped table-bordered table-hover" id="dataTables" style="width:100%">
<thead>
<tr>
<th width="100px" class="no-sort">Action</th>
<th>Title</th>
<th width="200px">Modifié le</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<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.timelines.first ? project.timelines.first.createdAt|date('d/m/Y H:i') : '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block localscript %}
<script>
$(document).ready(function() {
$('#dataTables').DataTable({
columnDefs: [ { "targets": "no-sort", "orderable": false }, { "targets": "no-string", "type" : "num" } ],
responsive: true,
iDisplayLength: 100,
order: [[ 2, "asc" ]]
});
});
</script>
{% endblock %}

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 group.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 and change[0] 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>

View File

@@ -0,0 +1,89 @@
{% extends 'base.html.twig' %}
{% block title %} = {{title}}{% endblock %}
{% block body %}
<h1 style="display:flex;">
<div style="flex-grow:1">{{title}}</div>
<div class="">{{group.status}}</div>
</h1>
<div class="mb-3">
{% if is_granted('UPDATE', group) %}
<a href="{{ path(routeupdate,{id:group.id}) }}" class="btn btn-success me-1">Modifier</a>
{% endif %}
<a href="{{ path(routecancel) }}" class="btn btn-secondary me-5">Retour</a>
{% if is_granted('CANSUBSCRIBE', group) %}
<a href="{{ path(routesubscribe,{id:group.id}) }}" class="btn btn-secondary me-1" onclick="return confirm('Participer à ce groupe ?')">
<i class="fas fa-users"></i> Participer à ce groupe
</a>
{% endif %}
{% if is_granted('MOVETOINACTIVE', group) and group.status=="Actif"%}
<a href="{{ path(routemove,{id:group.id, status:"INACTIVE"}) }}" class="btn btn-primary me-1" onclick="return confirm('Statut = Inactif ?')">
<i class="fas fa-angle-double-right"></i> Statut = Inactif
</a>
{% endif %}
{% if is_granted('DELETE', group) %}
<a href="{{ path(routedelete,{id:group.id}) }}" class="btn btn-danger float-end" onclick="return confirm('Confirmez-vous la suppression de cet enregistrement ?')">
Supprimer
</a>
{% endif %}
{% if is_granted('MOVETOACTIVE', group) and group.status=="Inactif" %}
<a href="{{ path(routemove,{id:group.id, status:"ACTIVE"}) }}" class="btn btn-warning me-1 float-end" onclick="return confirm('Statut = Actif ?')">
<i class="fas fa-angle-double-left"></i> Statut = Actif
</a>
{% endif %}
</div>
<div class="row">
<div class="col-md-4 mx-auto">
<div class="card mt-3">
<div class="card-header">Information</div>
<div class="card-body">
<b>Titre</b> = {{ group.title }}<br>
<b>Groupe Ouvert</b> = {{ group.open ? "Oui" : "Non" }}<br>
<b>A Voter pour le</b> = {{ group.dueDate ? group.dueDate|date("d/m/Y"):"" }}
</div>
</div>
<div class="card mt-3">
<div class="card-header">Résumé</div>
<div class="card-body">
{{ group.summary|nl2br }}
</div>
</div>
<div class="card mt-3">
<div class="card-header">Participants</div>
<div class="card-body">
{%for user in group.users%}
{{loop.first ? user.username : ' - '~user.username}}
{%endfor%}
</div>
</div>
{{ render(path("bninefiles_files",{domain:'group',id:group.id, editable:0})) }}
<div class="card mt-3">
<div class="card-header">Timeline</div>
<div class="card-body">
{% include('group/timeline.html.twig') %}
</div>
</div>
</div>
<div class="col-md-8 mx-auto">
<div class="card mt-3">
<div class="card-header">Pad du Group</div>
<div class="card-body">
{{ group.description ? group.description|markdown_to_html|nl2br : "" }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -3,45 +3,49 @@
{%block body%}
<h2>Projets</h2>
<ul id="tabProjects" class="nav nav-tabs">
<li class="me-5">
<a href="{{ path('app_user_project_submit') }}" class="btn btn-success">Ajouter</a>
<div class="row">
<div class="col-md-8">
<div class="d-flex align-items-center">
<h2>Mes Projets</h2>
<a href="{{ path('app_user_project_submit') }}" class="btn btn-success rounded-circle ms-3" title="Ajouter un projet"><i class="fas fa-plus" style="-webkit-text-stroke: 1px;"></i></a>
</div>
<ul id="tabProjects" class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link text-success nav-tome" href="#">Mes Projets</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#" data-status="A Voter"">A Voter</a>
<a class="nav-link active" href="#" data-status="A Voter"">A Voter</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-status="Brouillon">Brouillon</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-status="Voté">Voté</a>
</li>
<li class="nav-item ms-auto">
<a class="nav-link" href="#" data-status="Archivé">Archivé</a>
<a class="nav-link" href="#" data-status="Voté"><small>Voté</small></a>
</li>
<li class="nav-item">
<a class="nav-link text-success nav-tome" href="#">Mes Projets</a>
<a class="nav-link" href="#" data-status="Archivé"><small>Archivé</small></a>
</li>
</ul>
<div id="containerdraft" data-status="Brouillon" class='containerStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Brouillon</h3>
</div>
<div id="containervoted" data-status="A Voter" class='containerStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">A Voter</h3>
</div>
<div id="containertovote" data-status="Voté" class='containerStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Voté</h3>
</div>
<div id="containerarchived" data-status="Archivé" class='containerStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Archivé</h3>
</div>
</ul>
<div style="display:none">
<div id="containerdraft" data-status="Brouillon" class='containerProjectStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Brouillon</h3>
</div>
<div id="containervoted" data-status="A Voter" class='containerProjectStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">A Voter</h3>
</div>
<div id="containertovote" data-status="Voté" class='containerProjectStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Voté</h3>
</div>
<div id="containerarchived" data-status="Archivé" class='containerProjectStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Archivé</h3>
</div>
<div style="display:none">
{% for project in projects %}
{% if is_granted('VIEW', project) %}
<div class='card cardProject' data-status="{{project.status}}" data-tome="{{is_granted('TOME', project)}}" style='width:300px; margin-right:10px;'>
<div class='card cardProject' data-status="{{project.status}}" data-tome="{{is_granted('TOME', project)}}" style='width:300px; margin-right:10px;;margin-bottom:10px;'>
<div class='card-header d-flex justify-content-between align-items-center'>
<h5>{{project.title}}</h5>
@@ -80,7 +84,7 @@
<b>A Voter pour le</b> = {{ project.dueDate|date("d/m/Y") }}<br>
{% endif %}
<b>Nature</b> = {{ project.nature }}<br>
<b>Propriétaires</b> =
<b>Participants</b> =
{%for user in project.users%}
{{loop.first ? user.username : ' - '~user.username}}
{%endfor%}
@@ -89,35 +93,89 @@
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="d-flex align-items-center">
<h2>Groupes</h2>
<a href="{{ path('app_user_group_submit') }}" class="btn btn-success rounded-circle ms-3" title="Ajouter un groupe"><i class="fas fa-plus" style="-webkit-text-stroke: 1px;"></i></a>
</div>
<ul id="tabGroups" class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active text-success nav-tome" href="#">Mes Groupes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" data-status="Actif"">Actif</a>
</li>
<li class="nav-item ms-auto">
<a class="nav-link" href="#" data-status="Inactif"><small>Inactif</small></a>
</li>
</ul>
<div id="containeractif" data-status="Actif" class='containerGroupStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Actif</h3>
</div>
<div id="containerinactif" data-status="Inactif" class='containerGroupStatus d-flex flex-wrap mt-3' style='justify-content: left'>
<h3 style="width:100%">Inactif</h3>
</div>
<div style="display:none">
{% for group in groups %}
{% if is_granted('VIEW', group) %}
<div class='card cardGroup' data-status="{{group.status}}" data-tome="{{is_granted('TOME', group)}}" style='width:250px; margin-right:10px;margin-bottom:10px;'>
<div class='card-header d-flex justify-content-between align-items-center'>
<h5>{{group.title}}</h5>
<div>
<a href="{{ path("app_user_group_view",{id:group.id}) }}" class="btn btn-secondary btn-sm"><i class="fas fa-eye"></i></a>
</div>
</div>
<div class='card-body'>
{{group.summary|nl2br}}
</div>
<div class='card-footer' style="line-height:14px">
<small><em>
{% if group.dueDate %}
<b>A Voter pour le</b> = {{ group.dueDate|date("d/m/Y") }}<br>
{% endif %}
<b>Propriétaires</b> =
{%for user in group.users%}
{{loop.first ? user.username : ' - '~user.username}}
{%endfor%}
</em></small>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{%endblock%}
{% block localscript %}
<script>
$(document).ready(function() {
// Cacher tous les containers sauf celui actif au chargement
let initialStatus = $('.nav-link.active').data('status');
$('.containerStatus').removeClass("d-flex");
$('.containerStatus').hide();
$('.containerProjectStatus').removeClass("d-flex");
$('.containerProjectStatus').hide();
$('.cardProject').each(function () {
const $card = $(this);
const status = $card.data('status');
const $container = $('.containerStatus[data-status="' + status + '"]')
const $container = $('.containerProjectStatus[data-status="' + status + '"]')
if ($container.length) {
$container.append($card);
}
});
console.log(initialStatus);
$('.containerStatus[data-status="' + initialStatus + '"]').addClass("d-flex");
$('#tabProjects .nav-link').on('click', function (e) {
e.preventDefault();
// Gère les onglets actifs
$('.nav-link').removeClass('active');
$('#tabProjects .nav-link').removeClass('active');
$(this).addClass('active');
if ($(this).hasClass('nav-tome')) {
@@ -127,7 +185,7 @@
// On affiche que les cardProject avec tome
$(".cardProject[data-tome='1']").show();
$('.containerStatus').each(function () {
$('.containerProjectStatus').each(function () {
const $container = $(this);
const hasTome = $container.find('.cardProject[data-tome="1"]').length > 0;
@@ -146,11 +204,69 @@
let status = $(this).data('status');
// Cache tous les containers, puis affiche celui correspondant au data-status
$('.containerStatus').removeClass('d-flex').hide();
$('.containerStatus[data-status="' + status + '"]').addClass("d-flex").show();
$('.containerProjectStatus').removeClass('d-flex').hide();
$('.containerProjectStatus[data-status="' + status + '"]').addClass("d-flex").show();
}
});
$('#tabProjects .nav-link.active').click();
});
$(document).ready(function() {
// Cacher tous les containers sauf celui actif au chargement
$('.containerGroupStatus').removeClass("d-flex");
$('.containerGroupStatus').hide();
$('.cardGroup').each(function () {
const $card = $(this);
const status = $card.data('status');
const $container = $('.containerGroupStatus[data-status="' + status + '"]')
if ($container.length) {
$container.append($card);
}
});
$('#tabGroups .nav-link').on('click', function (e) {
e.preventDefault();
// Gère les onglets actifs
$('#tabGroups .nav-link').removeClass('active');
$(this).addClass('active');
if ($(this).hasClass('nav-tome')) {
// On masque tt les cardGroup
$(".cardGroup").hide();
// On affiche que les cardGroup avec tome
$(".cardGroup[data-tome='1']").show();
$('.containerGroupStatus').each(function () {
const $container = $(this);
const hasTome = $container.find('.cardGroup[data-tome="1"]').length > 0;
if (hasTome) {
$container.addClass('d-flex').show();
} else {
$container.removeClass('d-flex').hide();
}
});
}
else {
// On affiche tt les cardGroup
$(".cardGroup").show();
// Récupère le data-status sélectionné
let status = $(this).data('status');
// Cache tous les containers, puis affiche celui correspondant au data-status
$('.containerGroupStatus').removeClass('d-flex').hide();
$('.containerGroupStatus[data-status="' + status + '"]').addClass("d-flex").show();
}
});
$('#tabGroups .nav-link.active').click();
});
</script>
{% endblock %}

View File

@@ -21,6 +21,8 @@
<div class="card-header">Information</div>
<div class="card-body">
{{ form_row(form.title) }}
{{ form_row(form.open) }}
{{ form_row(form.nature) }}
{{ form_row(form.dueDate) }}
{{ form_row(form.users) }}
@@ -64,8 +66,8 @@
<a href="{{ path(routeupdateoption,{idproject:project.id,id:option.id}) }}" class="me-2"><i class="fas fa-file fa-2x"></i></a>
</td>
<td>{{option.title}}</td>
<td>{{option.whyYes?option.whyYes|markdown_to_html:""}}</td>
<td>{{option.whyNot?option.whyNot|markdown_to_html:""}}</td>
<td>{{option.whyYes?option.whyYes|markdown_to_html|nl2br:""}}</td>
<td>{{option.whyNot?option.whyNot|markdown_to_html|nl2br:""}}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -15,6 +15,12 @@
<a href="{{ path(routecancel) }}" class="btn btn-secondary me-5">Retour</a>
{% if is_granted('CANSUBSCRIBE', project) %}
<a href="{{ path(routesubscribe,{id:project.id}) }}" class="btn btn-secondary me-1" onclick="return confirm('Participer à ce projet ?')">
<i class="fas fa-users"></i> Participer à ce projet
</a>
{% endif %}
{% if is_granted('MOVETOVOTE', project) and project.status=="Brouillon"%}
<a href="{{ path(routemove,{id:project.id, status:"TOVOTE"}) }}" class="btn btn-primary me-1" onclick="return confirm('Statut = à Voter ?')">
<i class="fas fa-angle-double-right"></i> Statut = à Voter
@@ -59,6 +65,7 @@
<div class="card-header">Information</div>
<div class="card-body">
<b>Titre</b> = {{ project.title }}<br>
<b>Projet Ouvert</b> = {{ project.open ? "Oui" : "Non" }}<br>
<b>Nature</b> = {{ project.nature }}<br>
<b>A Voter pour le</b> = {{ project.dueDate ? project.dueDate|date("d/m/Y"):"" }}
</div>
@@ -71,6 +78,15 @@
</div>
</div>
<div class="card mt-3">
<div class="card-header">Participants</div>
<div class="card-body">
{%for user in project.users%}
{{loop.first ? user.username : ' - '~user.username}}
{%endfor%}
</div>
</div>
{{ render(path("bninefiles_files",{domain:'project',id:project.id, editable:0})) }}
<div class="card mt-3">
@@ -112,11 +128,11 @@
<div style="display:flex;">
<div style="flex:auto; padding:10px;">
<center><i class="fas fa-plus"></i></center>
{{option.whyYes?option.whyYes|markdown_to_html:""}}
{{option.whyYes?option.whyYes|markdown_to_html|nl2br:""}}
</div>
<div style="flex:auto; padding:10px;">
<center><i class="fas fa-minus"></i></center>
{{option.whyNot?option.whyNot|markdown_to_html:""}}
{{option.whyNot?option.whyNot|markdown_to_html|nl2br:""}}
</div>
</div>
</div>
@@ -125,7 +141,7 @@
<div class="card mt-3">
<div class="card-header">Description du Projet</div>
<div class="card-body">
{{ project.description ? project.description|markdown_to_html : "" }}
{{ project.description ? project.description|markdown_to_html|nl2br: "" }}
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@
{{ form_start(form) }}
{{ form_widget(form.submit) }}
<a href="{{ path(routecancel) }}" class="btn btn-secondary ms-1">Annuler</a>
{%if mode=="update" %}<a href="{{ path(routedelete,{id:form.vars.value.id}) }}" class="btn btn-danger float-end" onclick="return confirm('Confirmez-vous la suppression de cet enregistrement ?')">Supprimer</a>{%endif%}
@@ -31,13 +32,6 @@
{{ form_row(form.email) }}
</div>
</div>
<div class="card mt-3">
<div class="card-header">Redmine</div>
<div class="card-body">
{{ form_row(form.apikey) }}
</div>
</div>
</div>
{%if form.roles is defined%}