Implement voting for editor permissions (draft)

This commit is contained in:
Nicolò P 2024-11-11 17:21:36 +01:00
parent adee0f6d0f
commit 097bf6a272
14 changed files with 220 additions and 39 deletions

View File

@ -28,6 +28,11 @@ class BibliographyController extends AbstractController
$bibliography->setCollections($collections);
$bibliography->setCollectors($collectors);
$repo = $em->getRepository(Bibliography::class);
$isEditable = $repo->hasCreatorEditor($bibliography->getCreator());
$bibliography->setEditableStatus($isEditable);
return $this->render('bibliography/index.html.twig', [
'controller_name' => 'BibliographyController',
'record' => $bibliography,
@ -85,10 +90,11 @@ class BibliographyController extends AbstractController
return $this->redirectToRoute('app_home');
}
$em->remove($bibliography);
$em->flush();
$this->addFlash('notice', 'Record deleted successfully');
if ($bibliography) {
$em->remove($bibliography);
$em->flush();
$this->addFlash('notice', 'Record deleted successfully');
}
return $this->redirectToRoute('app_bibliography_landing');
}
@ -100,7 +106,7 @@ class BibliographyController extends AbstractController
public function copy(Bibliography $bibliography, EntityManagerInterface $em): Response
{
try {
$this->denyAccessUnlessGranted(RecordVoter::EDIT, $bibliography);
$this->denyAccessUnlessGranted(RecordVoter::COPY, $bibliography);
}
catch (AccessDeniedException) {
$this->addFlash('warning', 'You are not authorized to copy this record');

View File

@ -20,9 +20,12 @@ class CollectionController extends AbstractController
{
$repo = $em->getRepository(Bibliography::class);
$bibliographies = $repo->findAllByCollection($collection->getId());
$collection->setBibliographies($bibliographies);
$repo = $em->getRepository(Collection::class);
$isEditable = $repo->hasCreatorEditor($collection->getCreator());
$collection->setEditableStatus($isEditable);
return $this->render('collection/index.html.twig', [
'controller_name' => 'CollectionController',
'record' => $collection,
@ -56,8 +59,10 @@ class CollectionController extends AbstractController
return $this->redirectToRoute('app_home');
}
$em->remove($collection);
$em->flush();
if ($collection) {
$em->remove($collection);
$em->flush();
}
$this->addFlash('notice', 'Record deleted successfully');

View File

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Entity\Collector;
use App\Entity\Collection;
use App\Entity\Bibliography;
use App\RecordStatus;
use App\Security\Voter\RecordVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -21,6 +22,10 @@ class CollectorController extends AbstractController
$bibliographies = $repo->findAllByCollector($collector->getId());
$collector->setBibliographies($bibliographies);
$repo = $em->getRepository(Collector::class);
$isEditable = $repo->hasCreatorEditor($collector->getCreator());
$collector->setEditableStatus($isEditable);
return $this->render('collector/index.html.twig', [
'controller_name' => 'CollectorController',
'record' => $collector,
@ -61,4 +66,40 @@ class CollectorController extends AbstractController
return $this->redirectToRoute('app_collector_landing');
}
/**
* @todo Move clone logic to __clone() in Entity or Repository
*/
#[Route('/collector/copy/{id<\d+>}', name: 'app_collector_copy')]
public function copy(Collector $collector, EntityManagerInterface $em): Response
{
try {
$this->denyAccessUnlessGranted(RecordVoter::EDIT, $collector);
}
catch (AccessDeniedException) {
$this->addFlash('warning', 'You are not authorized to copy this record');
return $this->redirectToRoute('app_home');
}
$user = $this->getUser();
$editor = "{$user->getFirstname()} {$user->getLastName()}";
$copy = clone $collector;
$copy->setEditor($editor);
$copy->setOwner($editor);
$copy->setCreator($user->getUsername());
$repo = $em->getRepository(Bibliography::class);
$copy->setBibliographies(
$repo->findAllByCollector($collector->getId())
);
$copy->setName("{$collector->getName()} - Copy");
$copy->setModifiedAt(new \DateTimeImmutable());
$copy->setStatus(RecordStatus::Draft->value);
$em->persist($copy);
$em->flush();
$this->addFlash('notice', 'Record copied successfully');
return $this->redirectToRoute('app_collector', ['id' => $copy->getId()]);
}
}

View File

@ -4,14 +4,14 @@ namespace App\Entity;
use App\RecordInterface;
use App\Repository\BibliographyRepository;
use App\Repository\CollectionRepository;
use App\Repository\CollectorRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\DBAL\Types\Types;
use App\RecordStatus;
use ContainerBNNizmi\getBibliographyRepositoryService;
use Doctrine\Common\Collections\Collection as DoctrineCollection;
use Doctrine\DBAL\Query\QueryBuilder as QueryQueryBuilder;
use Doctrine\ORM\QueryBuilder;
#[ORM\Entity(repositoryClass: BibliographyRepository::class)]
#[ORM\Table(name: 'bibliography')]
@ -64,6 +64,9 @@ class Bibliography implements RecordInterface
private DoctrineCollection $sites;
// Checks if the record can be edited by an 'editor' user
private bool $isEditable = false;
public function getId(): ?int
{
return $this->id;
@ -189,4 +192,16 @@ class Bibliography implements RecordInterface
return $this;
}
public function getEditableStatus(): bool
{
return $this->isEditable;
}
public function setEditableStatus(bool $status): static
{
$this->isEditable = $status;
return $this;
}
}

View File

@ -83,6 +83,8 @@ class Collection implements RecordInterface
#[ORM\ManyToMany(targetEntity: Bibliography::class, inversedBy: 'collection')]
private DoctrineCollection $bibliographies;
private bool $isEditable = false;
public function getId(): ?int
{
return $this->id;
@ -292,4 +294,16 @@ class Collection implements RecordInterface
return $this;
}
public function getEditableStatus(): bool
{
return $this->isEditable;
}
public function setEditableStatus(bool $status): static
{
$this->isEditable = $status;
return $this;
}
}

View File

@ -83,6 +83,8 @@ class Collector implements RecordInterface
#[ORM\ManyToMany(targetEntity: Bibliography::class, inversedBy: 'collector')]
private DoctrineCollection $bibliographies;
private bool $isEditable = false;
public function getId(): ?int
{
return $this->id;
@ -292,4 +294,16 @@ class Collector implements RecordInterface
return $this;
}
public function getEditableStatus(): bool
{
return $this->isEditable;
}
public function setEditableStatus(bool $status): static
{
$this->isEditable = $status;
return $this;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Bibliography;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Common\Collections\ArrayCollection;
@ -18,6 +19,28 @@ class BibliographyRepository extends ServiceEntityRepository
parent::__construct($registry, Bibliography::class);
}
public function hasCreatorEditor(string $creator): bool
{
$em = $this->getEntityManager();
$repo = $em->getRepository(User::class);
$creator = $repo->findOneBy(['username' => $creator]);
return in_array('ROLE_EDITOR', $creator->getRoles());
}
/*
public function delete(int $id): void
{
$qb = $this->createQueryBuilder('b')
->delete(Bibliography::class, 'bib')
->where('bib.id = :id')
->setParameter('id', $id);
$qb->getQuery()->execute();
}
*/
public function findAllByCollection(int $collectionId): ?ArrayCollection
{
$rsm = new ResultSetMappingBuilder($this->getEntityManager());

View File

@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Collection;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Common\Collections\ArrayCollection;
@ -18,6 +19,16 @@ class CollectionRepository extends ServiceEntityRepository
parent::__construct($registry, Collection::class);
}
public function hasCreatorEditor(string $creator): bool
{
$em = $this->getEntityManager();
$repo = $em->getRepository(User::class);
$creator = $repo->findOneBy(['username' => $creator]);
return in_array('ROLE_EDITOR', $creator->getRoles());
}
public function findAllByBibliography(int $biblioId): ?ArrayCollection
{
$rsm = new ResultSetMappingBuilder($this->getEntityManager());

View File

@ -3,6 +3,7 @@
namespace App\Repository;
use App\Entity\Collector;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Common\Collections\ArrayCollection;
@ -18,6 +19,16 @@ class CollectorRepository extends ServiceEntityRepository
parent::__construct($registry, Collector::class);
}
public function hasCreatorEditor(string $creator): bool
{
$em = $this->getEntityManager();
$repo = $em->getRepository(User::class);
$creator = $repo->findOneBy(['username' => $creator]);
return in_array('ROLE_EDITOR', $creator->getRoles());
}
public function findAllByBibliography(int $biblioId): ?ArrayCollection
{
$rsm = new ResultSetMappingBuilder($this->getEntityManager());

View File

@ -5,16 +5,21 @@ namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
use App\Entity\User;
use App\RecordStatus;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManager;
final class RecordVoter extends Voter
{
public const EDIT = 'RECORD_EDIT';
public const DELETE = 'RECORD_DELETE';
public const COPY = 'RECORD_COPY';
public const VIEW = 'RECORD_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE])
return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE, self::COPY])
&& $subject instanceof \App\RecordInterface;
}
@ -32,11 +37,37 @@ final class RecordVoter extends Voter
// TODO: Better way to check roles?
switch ($attribute) {
case self::EDIT:
case self::DELETE:
$editorAllowed = true;
if (in_array('ROLE_EDITOR', $roles)) {
$creator = $subject->getCreator();
$isCreator = $creator === $user->getUsername();
$creatorIsEditor = $subject->getEditableStatus();
$editorAllowed &= $isCreator || $creatorIsEditor;
}
return in_array('ROLE_ADMIN', $roles)
|| in_array('ROLE_REVISOR', $roles);
|| in_array('ROLE_REVISOR', $roles)
|| $editorAllowed;
case self::DELETE:
$editorAllowed = true;
if (in_array('ROLE_EDITOR', $roles)) {
$isCreator = $subject->getCreator() === $user->getUsername();
$isValidStatus = match($subject->getStatus()) {
RecordStatus::Draft => true,
RecordStatus::Complete => true,
default => false
};
$editorAllowed &= $isCreator && $isValidStatus;
}
return in_array('ROLE_ADMIN', $roles)
|| in_array('ROLE_REVISOR', $roles)
|| $editorAllowed;
break;
case self::COPY:
case self::VIEW:
return ! in_array('ROLE_READER', $roles);
break;

View File

@ -27,16 +27,20 @@
</article>
<div class="card p-5">
{% if is_granted('ROLE_ADMIN') or is_granted('ROLE_REVISOR') %}
{% if not is_granted('ROLE_READER') %}
<div class="columns">
<div class="column is-half"></div>
<div class="column has-text-right">
{% if is_granted('ROLE_REVISOR') or
is_granted('ROLE_ADMIN') or
record.editableStatus %}
<button class="button is-link">
Edit
<span class="icon ml-2">
<i class="fa fa-edit"></i>
</span>
</button>
{% endif %}
<a href="{{ path('app_bibliography_copy', {'id' : record.id}) }}"
class="button is-link">
Copy
@ -44,14 +48,16 @@
<i class="fa fa-copy"></i>
</span>
</a>
<a href="{{ path('app_bibliography_del', {'id' : record.id}) }}"
{% if is_granted('ROLE_REVISOR') or is_granted('ROLE_ADMIN') %}
<button data-url="{{ path('app_bibliography_del', {'id' : record.id}) }}"
class="button is-danger"
data-delete-record-target="path" data-action="click->delete-record#show">
data-delete-record-target="path" data-action="click->delete-record#warn">
Delete
<span class="icon ml-2">
<i class="fa fa-trash"></i>
</span>
</a>
</button>
{% endif %}
</div>
</div>
{% endif %}

View File

@ -43,14 +43,14 @@
<i class="fa fa-copy"></i>
</span>
</a>
<a href="{{ path('app_collection_del', {'id' : record.id}) }}"
<button data-url="{{ path('app_collection_del', {'id' : record.id}) }}"
class="button is-danger"
data-delete-record-target="path" data-action="click->delete-record#show">
data-delete-record-target="path" data-action="click->delete-record#warn">
Delete
<span class="icon ml-2">
<i class="fa fa-trash"></i>
</span>
</a>
</button>
</div>
</div>
{% endif %}
@ -74,7 +74,7 @@
<tr><th>External identifier(s)</th><td>{{ record.externalIdentifier }}</td></tr>
<tr><th>External link(s)</th><td>{{ record.link }}</td></tr>
<tr><th>Subject headings</th><td>{{ record.subjectHeadings }}</td></tr>
<tr><th>ArCOA URI</th><td>{{ record.uri }}</td></tr>
<tr><th>ArCOA URI</th><td>arcoa.cnr.it/collection/{{ record.id }}</td></tr>
<tr><th>Editorial notes</th><td>{{ record.notes }}</td></tr>
</table>
</div>

View File

@ -7,6 +7,15 @@
<h1 class="is-size-1 mt-0 has-text-centered">Collector</h1>
<h2 class="is-size-3 mt-3 has-text-centered">{{ record.name }}</h2>
{% for message in app.flashes('notice') %}
<div class=" mt-4 notification is-success"
data-controller="notification"
data-notification-target="notif">
<button class="delete" data-action="click->notification#close"></button>
{{ message }}
</div>
{% endfor %}
<article class="message is-info mt-3">
<div class="message-body">
<p>
@ -27,20 +36,21 @@
<i class="fa fa-edit"></i>
</span>
</button>
<button class="button is-link">
<a href="{{ path('app_collector_copy', {'id' : record.id}) }}"
class="button is-link">
Copy
<span class="icon ml-2">
<i class="fa fa-copy"></i>
</span>
</button>
<a href=""
</a>
<button data-url="{{ path('app_collector_del', {'id' : record.id}) }}"
class="button is-danger"
data-delete-record-target="path" data-action="click->delete-record#show">
data-delete-record-target="path" data-action="click->delete-record#warn">
Delete
<span class="icon ml-2">
<i class="fa fa-trash"></i>
</span>
</a>
</button>
</div>
</div>
{% endif %}
@ -64,7 +74,7 @@
<tr><th>External identifier(s)</th><td>{{ record.externalIdentifier }}</td></tr>
<tr><th>External link(s)</th><td>{{ record.link }}</td></tr>
<tr><th>Subject headings</th><td>{{ record.subjectHeadings }}</td></tr>
<tr><th>ArCOA URI</th><td>{{ record.uri }}</td></tr>
<tr><th>ArCOA URI</th><td>arcoa.cnr.it/collector/{{ record.id }}</td></tr>
<tr><th>Editorial notes</th><td>{{ record.notes }}</td></tr>
</table>
</div>

View File

@ -4,10 +4,13 @@
{% block rightpanel %}
{% for message in app.flashes('warning') %}
<article class="message is-warning mb-6 mt-3 ml-auto mr-auto" style="max-width: 35vw">
<article class="message is-warning mb-6 mt-3 ml-auto mr-auto" style="max-width: 35vw"
data-controller="notification"
data-notification-target="notif">
<div class="message-header">
<p>Warning</p>
<button class="delete" aria-label="delete"></button>
<button class="delete" aria-label="delete"
data-action="click->notification#close"></button>
</div>
<div class="message-body">{{ message }}</div>
</article>
@ -40,13 +43,4 @@
</div>
</div>
</div>
<script type="text/javascript" defer>
const warning = document.querySelector('.is-warning');
if (warning) {
warning.querySelector('.delete').addEventListener('click', () => {
warning.classList.add('is-hidden');
});
}
</script>
{% endblock %}