Commit 3ed51efa authored by Emma's avatar Emma 🦉

add admin-only user list with some filter choices

parent 036ea10f
Pipeline #15885969 failed with stage
in 6 minutes and 1 second
......@@ -23,6 +23,13 @@ user_comments:
methods: [GET]
requirements: { page: \d+ }
users:
controller: App\Controller\UserController::list
defaults: { page: 1 }
path: /users/{page}
methods: [GET]
requirements: { page: \d+ }
edit_user:
controller: App\Controller\UserController::editUser
path: /edit_user/{username}
......
......@@ -2,6 +2,8 @@
namespace App\Controller;
use App\Form\Model\UserFilterData;
use App\Form\UserFilterType;
use Doctrine\ORM\EntityManager;
use App\Entity\User;
use App\Entity\UserBlock;
......@@ -63,6 +65,33 @@ final class UserController extends AbstractController {
]);
}
/**
* @IsGranted("ROLE_ADMIN")
*
* @param UserRepository $users
* @param int $page
* @param Request $request
*
* @return Response
*/
public function list(UserRepository $users, int $page, Request $request) {
$filter = new UserFilterData();
$criteria = $filter->buildCriteria();
$form = $this->createForm(UserFilterType::class, $filter);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$criteria = $filter->buildCriteria();
}
return $this->render('user/list.html.twig', [
'form' => $form->createView(),
'page' => $page,
'users' => $users->findPaginated($page, $criteria),
]);
}
/**
* User registration form.
*
......
......@@ -144,7 +144,7 @@ class Comment extends Votable {
$this->setUserFlag($userFlag);
$this->user = $user;
$this->submission = $submission;
$this->ip = $user->isTrusted() ? null : $ip;
$this->ip = $user->isTrustedOrAdmin() ? null : $ip;
$this->timestamp = $timestamp ?: new \DateTime('@'.time());
$this->children = new ArrayCollection();
$this->votes = new ArrayCollection();
......
......@@ -12,6 +12,9 @@ use Doctrine\ORM\Mapping as ORM;
* columns={"comment_id", "user_id"}
* )
* })
* @ORM\AssociationOverrides({
* @ORM\AssociationOverride(name="user", inversedBy="commentVotes")
* })
*/
class CommentVote extends Vote {
/**
......
......@@ -53,7 +53,7 @@ abstract class Message {
$this->sender = $sender;
$this->body = $body;
$this->ip = $sender->isTrusted() ? null : $ip;
$this->ip = $sender->isTrustedOrAdmin() ? null : $ip;
$this->timestamp = new \DateTime('@'.time());
}
......
......@@ -171,7 +171,7 @@ class Submission extends Votable {
$this->body = $body;
$this->forum = $forum;
$this->user = $user;
$this->ip = $user->isTrusted() ? null : $ip;
$this->ip = $user->isTrustedOrAdmin() ? null : $ip;
$this->sticky = $sticky;
$this->setUserFlag($userFlag);
$this->timestamp = $timestamp ?: new \DateTime('@'.time());
......
......@@ -12,6 +12,9 @@ use Doctrine\ORM\Mapping as ORM;
* columns={"submission_id", "user_id"}
* )
* })
* @ORM\AssociationOverrides({
* @ORM\AssociationOverride(name="user", inversedBy="submissionVotes")
* })
*/
class SubmissionVote extends Vote {
/**
......
......@@ -105,7 +105,7 @@ class User implements UserInterface, EquatableInterface {
private $moderatorTokens;
/**
* @ORM\OneToMany(targetEntity="Submission", mappedBy="user")
* @ORM\OneToMany(targetEntity="Submission", mappedBy="user", fetch="EXTRA_LAZY")
* @ORM\OrderBy({"id": "DESC"})
*
* @var Submission[]|Collection|Selectable
......@@ -113,13 +113,27 @@ class User implements UserInterface, EquatableInterface {
private $submissions;
/**
* @ORM\OneToMany(targetEntity="Comment", mappedBy="user")
* @ORM\OneToMany(targetEntity="SubmissionVote", mappedBy="user", fetch="EXTRA_LAZY")
*
* @var SubmissionVote[]|Collection
*/
private $submissionVotes;
/**
* @ORM\OneToMany(targetEntity="Comment", mappedBy="user", fetch="EXTRA_LAZY")
* @ORM\OrderBy({"id": "DESC"})
*
* @var Comment[]|Collection|Selectable
*/
private $comments;
/**
* @ORM\OneToMany(targetEntity="CommentVote", mappedBy="user", fetch="EXTRA_LAZY")
*
* @var CommentVote[]|Collection
*/
private $commentVotes;
/**
* @ORM\OneToMany(targetEntity="UserBan", mappedBy="user")
*
......@@ -343,6 +357,10 @@ class User implements UserInterface, EquatableInterface {
return $submissions;
}
public function getSubmissionVotes(): Collection {
return $this->submissionVotes;
}
/**
* @return Collection|Selectable|Comment[]
*/
......@@ -364,6 +382,10 @@ class User implements UserInterface, EquatableInterface {
return $comments;
}
public function getCommentVotes(): Collection {
return $this->commentVotes;
}
/**
* @return UserBan[]|Collection
*/
......@@ -449,6 +471,10 @@ class User implements UserInterface, EquatableInterface {
}
public function isTrusted(): bool {
return $this->trusted;
}
public function isTrustedOrAdmin(): bool {
return $this->admin || $this->trusted;
}
......
......@@ -101,7 +101,7 @@ abstract class Vote {
throw new \InvalidArgumentException('Bad IP address');
}
$this->ip = $this->user->isTrusted() ? null : $ip;
$this->ip = $this->user->isTrustedOrAdmin() ? null : $ip;
}
/**
......
......@@ -62,7 +62,7 @@ final class BanListener implements EventSubscriberInterface {
return;
}
if ($user->isTrusted()) {
if ($user->isTrustedOrAdmin()) {
// don't check for ip bans
return;
}
......
<?php
namespace App\Form\Model;
use Doctrine\Common\Collections\Criteria;
class UserFilterData {
const ROLE_ANY = 'any';
const ROLE_ADMIN = 'admin';
const ROLE_TRUSTED = 'trusted';
const ROLE_NONE = 'none';
const ORDER_CREATED = 'created';
const ORDER_USERNAME = 'username';
/**
* @var string
*/
private $role = self::ROLE_ANY;
/**
* @var string
*/
private $orderBy = self::ORDER_CREATED;
public function buildCriteria(): Criteria {
$criteria = Criteria::create();
switch ($this->role) {
case self::ROLE_ANY:
// do nothing
break;
case self::ROLE_ADMIN:
$criteria->where(Criteria::expr()->eq('admin', true));
break;
case self::ROLE_TRUSTED:
$criteria->where(Criteria::expr()->eq('trusted', true));
$criteria->andWhere(Criteria::expr()->eq('admin', false));
break;
case self::ROLE_NONE:
$criteria->where(Criteria::expr()->eq('admin', false));
$criteria->andWhere(Criteria::expr()->eq('trusted', false));
break;
default:
throw new \DomainException('Unknown role choice');
}
switch ($this->orderBy) {
case self::ORDER_CREATED:
$criteria
->orderBy(['id' => 'DESC']);
break;
case self::ORDER_USERNAME:
$criteria
->orderBy(['canonicalUsername' => 'ASC']);
break;
default:
throw new \DomainException('Unknown order choice');
}
return $criteria;
}
public function getRole(): string {
return $this->role;
}
public function setRole(string $role): void {
$this->role = $role;
}
public function getOrderBy(): string {
return $this->orderBy;
}
public function setOrderBy(string $orderBy): void {
$this->orderBy = $orderBy;
}
}
<?php
namespace App\Form;
use App\Form\Model\UserFilterData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserFilterType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('orderBy', ChoiceType::class, [
'label' => 'label.order_by',
'choices' => [
'label.registration_date' => UserFilterData::ORDER_CREATED,
'label.username' => UserFilterData::ORDER_USERNAME,
]
])
->add('role', ChoiceType::class, [
'label' => 'label.role',
'choices' => [
'label.any' => UserFilterData::ROLE_ANY,
'label.admin' => UserFilterData::ROLE_ADMIN,
'label.trusted' => UserFilterData::ROLE_TRUSTED,
'label.none' => UserFilterData::ROLE_NONE,
],
]);
}
public function configureOptions(OptionsResolver $resolver) {
$resolver->setDefaults([
'csrf_protection' => false,
'data_class' => UserFilterData::class,
'method' => 'GET',
]);
}
}
......@@ -7,8 +7,11 @@ use App\Entity\Submission;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\Query\ResultSetMapping;
use Pagerfanta\Adapter\DoctrineSelectableAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
/**
......@@ -112,4 +115,18 @@ EOSQL;
return $combined;
}
/**
* @param int $page
* @param Criteria $criteria
*
* @return User[]|Pagerfanta
*/
public function findPaginated(int $page, Criteria $criteria) {
$pager = new Pagerfanta(new DoctrineSelectableAdapter($this, $criteria));
$pager->setMaxPerPage(25);
$pager->setCurrentPage($page);
return $pager;
}
}
......@@ -55,7 +55,7 @@ final class WikiVoter extends Voter {
}
// TODO: make this configurable
if (!$user->isTrusted() && $user->getCreated() > new \DateTime('@'.time().' -24 hours')) {
if (!$user->isTrustedOrAdmin() && $user->getCreated() > new \DateTime('@'.time().' -24 hours')) {
return false;
}
......
{% with {attr: app.request.attributes} %}
{% with {attr: app.request.attributes, get: app.request.query.all} %}
{% if pager.hasPreviousPage %}
<link rel="prev" href="{{ path(attr.get('_route'), attr.get('_route_params')|default({})|merge({page: pager.previousPage})) }}">
<link rel="prev" href="{{ path(attr.get('_route'), (attr.get('_route_params') ?? {})|merge(get)|merge({page: pager.previousPage})) }}">
{% endif %}
{% if pager.hasNextPage %}
<link rel="next" href="{{ path(attr.get('_route'), attr.get('_route_params')|default({})|merge({page: pager.nextPage})) }}">
<link rel="next" href="{{ path(attr.get('_route'), (attr.get('_route_params') ?? {})|merge(get)|merge({page: pager.nextPage})) }}">
{% endif %}
{% endwith %}
{% with {attr: app.request.attributes, hasPrev: pager.hasPreviousPage, hasNext: pager.hasNextPage} %}
{% with {
attr: app.request.attributes,
get: app.request.query.all,
hasPrev: pager.hasPreviousPage,
hasNext: pager.hasNextPage,
} %}
{% if hasPrev or hasNext %}
<nav class="pagination">
<ul>
{% if hasPrev %}
<li class="previous">
<a href="{{ path(attr.get('_route'), attr.get('_route_params')|default({})|merge({page: pager.previousPage})) }}">
<a href="{{ path(attr.get('_route'), (attr.get('_route_params') ?? {})|merge(get)|merge({page: pager.previousPage})) }}">
{{ 'nav.previous'|trans }}
</a>
</li>
{% endif %}
{% if hasNext %}
<li class="next">
<a href="{{ path(attr.get('_route'), attr.get('_route_params')|default({})|merge({page: pager.nextPage})) }}">
<a href="{{ path(attr.get('_route'), (attr.get('_route_params') ?? {})|merge(get)|merge({page: pager.nextPage})) }}">
{{ 'nav.next'|trans }}
</a>
</li>
......
......@@ -25,6 +25,7 @@
<a href="#" class="site-nav__link dropdown-toggle">{{ 'label.admin'|trans }}</a>
<ul class="dropdown-menu">
<li><a href="{{ path('user_bans') }}">{{ icon('hammer') }} {{ 'label.bans'|trans }}</a></li>
<li><a href="{{ path('users') }}">{{ icon('user') }} {{ 'nav.users'|trans }}</a></li>
</ul>
</li>
{% endif %}
......
{% extends 'base.html.twig' %}
{% block page_classes 'user-list-page' %}
{% block title 'title.list_of_users'|trans({'%page%': page|localizednumber}) %}
{% block head %}
{{ include('_includes/meta_pagination.html.twig', {pager: users}, with_context=false) }}
{% endblock %}
{% block body %}
<h1 class="page-heading">{{ block('title') }}</h1>
<details>
<summary>{{ 'nav.filter_results'|trans }}</summary>
{{ form_start(form) }}
{{ form_rest(form) }}
<div class="form__row">
<button class="button">{{ 'action.filter'|trans }}</button>
</div>
{{ form_end(form) }}
</details>
{% from 'user/_macros.html.twig' import user_link %}
<table class="table">
<thead>
<tr>
<th>{{ 'label.id'|trans }}</th>
<th>{{ 'label.username'|trans }}</th>
<th>{{ 'label.registration_date'|trans }}</th>
<th>{{ 'label.role'|trans }}</th>
<th>{{ 'label.moderates'|trans }}</th>
<th><abbr title="{{ 'label.submissions'|trans }}">{{ 'label.submissions_short'|trans }}</abbr></th>
<th><abbr title="{{ 'label.comments'|trans }}">{{ 'label.comments_short'|trans }}</abbr></th>
<th><abbr title="{{ 'label.submission_votes'|trans }}">{{ 'label.submission_votes_short'|trans }}</abbr></th>
<th><abbr title="{{ 'label.comment_votes'|trans }}">{{ 'label.comment_votes_short'|trans }}</abbr></th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user_link(user) }}</td>
<td>
{% with { date: user.created|localizeddate('long', 'short')} %}
<time datetime="{{ user.created|date('c') }}" class="relative-time" title="{{ date }}">
{{- date -}}
</time>
{% endwith %}
</td>
<td>{{ user.admin ? ('label.admin'|trans) : user.trusted ? ('label.trusted'|trans) : '-' }}</td>
<td>
{% with { count: user.moderatorTokens|length } %}
{{ count > 0 ? 'label.forums_count'|transchoice(count) : '-' }}
{% endwith %}
</td>
<td>
{% with { count: user.submissions|length } %}
{{ count > 0 ? count|localizednumber : '-' }}
{% endwith %}
</td>
<td>
{% with { count: user.comments|length } %}
{{ count > 0 ? count|localizednumber : '-' }}
{% endwith %}
</td>
<td>
{% with { count: user.submissionVotes|length } %}
{{ count > 0 ? count|localizednumber : '-' }}
{% endwith %}
</td>
<td>
{% with { count: user.commentVotes|length } %}
{{ count > 0 ? count|localizednumber : '-' }}
{% endwith %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ include('_includes/pagination.html.twig', {pager: users}, with_context=false) }}
{% endblock %}
......@@ -22,6 +22,7 @@ action:
delete: Delete
remove: Remove
global_ban: Global ban
filter: Filter
add_moderator:
title: Add moderator to %forum%
......@@ -250,6 +251,22 @@ label:
ban_ip_address: Ban IP address?
ip_address: IP address
forum: Forum
trusted: Trusted
submissions: Submissions
submissions_short: S
comments: Comments
comments_short: C
submission_votes: Submission votes
submission_votes_short: SV
comment_votes: Comment votes
comment_votes_short: CV
role: Role
moderates: Moderates
forums_count: '{0} No forums|{1} %count% forum|]1,Inf[ %count% forums'
any: Any
none: None
order_by: Order by
registration_date: Registration date
log:
comment_deletion: '%user% deleted comment by %author% in "%submission%"'
......@@ -309,6 +326,8 @@ nav:
add_ban: Add ban
css: CSS
theme_settings: Theme settings
filter_results: Filter results
users: Users
placeholder:
default: (default)
......@@ -412,6 +431,7 @@ title:
unban_user: Unbanning %user%
global_moderation_log: Global moderation log
delete_submission: Delete submission
list_of_users: 'List of users, page #%page%'
user:
submissions: Submissions
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment