Merge branch 'feature/visit' into develop
This commit is contained in:
commit
0575c8ce59
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -27,3 +27,5 @@ yarn-error.log
|
|||
/public/uploads/
|
||||
!/public/uploads/.gitkeep
|
||||
/public/media/
|
||||
/migrations/
|
||||
!/migrations/.gitkeep
|
||||
|
|
|
@ -511,6 +511,8 @@ form {
|
|||
|
||||
.modal-dialog-large {
|
||||
max-width: 80%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.output {
|
||||
|
|
|
@ -23,3 +23,4 @@ require('./modules/sortable.js')()
|
|||
require('./modules/batch.js')()
|
||||
require('./modules/file-manager.js')()
|
||||
require('./modules/file-picker.js')()
|
||||
require('./modules/analytics.js')()
|
||||
|
|
53
assets/js/admin/modules/analytics.js
Normal file
53
assets/js/admin/modules/analytics.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const $ = require('jquery')
|
||||
const Chart = require('chart.js/auto').default
|
||||
|
||||
const drawChart = () => {
|
||||
const ctx = document.getElementById('analytic-chart')
|
||||
const options = {
|
||||
type: 'bar',
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
},
|
||||
data: {
|
||||
labels: JSON.parse(ctx.getAttribute('data-labels')),
|
||||
datasets: [{
|
||||
label: ctx.getAttribute('data-label'),
|
||||
data: JSON.parse(ctx.getAttribute('data-values')),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const chart = new Chart(ctx, options)
|
||||
|
||||
const resize = () => {
|
||||
const width = ctx.parentNode.parentNode.offsetWidth
|
||||
const height = 250
|
||||
|
||||
chart.resize(width, height)
|
||||
}
|
||||
|
||||
resize()
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
const body = $('body')
|
||||
|
||||
body.on('shown.bs.modal', '.modal', (e) => {
|
||||
window.setTimeout(() => {
|
||||
if (document.getElementById('analytic-chart')) {
|
||||
drawChart()
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
}
|
|
@ -3,8 +3,10 @@ const $ = require('jquery')
|
|||
const openModal = function (url) {
|
||||
let container = $('#modal-container')
|
||||
const body = $('body')
|
||||
let doTrigger = true
|
||||
|
||||
if (!container.length) {
|
||||
let doTrigger = false
|
||||
container = $('<div id="modal-container" class="modal">')
|
||||
|
||||
body.append(container)
|
||||
|
@ -20,6 +22,10 @@ const openModal = function (url) {
|
|||
|
||||
container.load(url, function () {
|
||||
loader.remove()
|
||||
|
||||
if (doTrigger) {
|
||||
container.trigger('shown.bs.modal')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
"doctrine/orm": "^2.8",
|
||||
"friendsofsymfony/jsrouting-bundle": "^2.7",
|
||||
"jaybizzle/crawler-detect": "^1.2",
|
||||
"knplabs/doctrine-behaviors": "^2.2",
|
||||
"knplabs/knp-paginator-bundle": "^5.8",
|
||||
"liip/imagine-bundle": "^2.6",
|
||||
|
|
|
@ -23,6 +23,10 @@ services:
|
|||
tags:
|
||||
- { name: kernel.event_listener, event: kernel.exception }
|
||||
|
||||
App\Core\EventListener\AnalyticListener:
|
||||
tags:
|
||||
- { name: kernel.event_listener, event: kernel.request }
|
||||
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
|
|
186
core/Analytic/DateRangeAnalytic.php
Normal file
186
core/Analytic/DateRangeAnalytic.php
Normal file
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Analytic;
|
||||
|
||||
use App\Core\Entity\Site\Node;
|
||||
use App\Core\Repository\Analytic\RefererRepositoryQuery;
|
||||
use App\Core\Repository\Analytic\ViewRepositoryQuery;
|
||||
|
||||
/**
|
||||
* class DateRangeAnalytic.
|
||||
*
|
||||
* @author Simon Vieille <simon@deblan.fr>
|
||||
*/
|
||||
class DateRangeAnalytic
|
||||
{
|
||||
protected ViewRepositoryQuery $viewQuery;
|
||||
protected RefererRepositoryQuery $refererQuery;
|
||||
protected ?Node $node;
|
||||
protected ?\DateTime $from;
|
||||
protected ?\DateTime $to;
|
||||
protected bool $reload = true;
|
||||
protected array $cache = [];
|
||||
|
||||
public function __construct(ViewRepositoryQuery $viewQuery, RefererRepositoryQuery $refererQuery)
|
||||
{
|
||||
$this->viewQuery = $viewQuery;
|
||||
$this->refererQuery = $refererQuery;
|
||||
}
|
||||
|
||||
public function getViews(): array
|
||||
{
|
||||
$entities = $this->getEntities('view');
|
||||
$this->reload = false;
|
||||
|
||||
if ($entities) {
|
||||
$first = $entities[0];
|
||||
$last = $entities[count($entities) - 1];
|
||||
|
||||
$diff = $first->getDate()->diff($last->getDate());
|
||||
|
||||
if ($diff->days >= 90) {
|
||||
$format = 'Y-m';
|
||||
} else {
|
||||
$format = 'Y-m-d';
|
||||
}
|
||||
}
|
||||
|
||||
$datas = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$index = $entity->getDate()->format($format);
|
||||
|
||||
if (!isset($datas[$index])) {
|
||||
$datas[$index] = 0;
|
||||
}
|
||||
|
||||
$datas[$index] += $entity->getViews();
|
||||
}
|
||||
|
||||
arsort($datas, SORT_NUMERIC);
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
public function getPathViews(): array
|
||||
{
|
||||
$entities = $this->getEntities('view');
|
||||
$this->reload = false;
|
||||
|
||||
$datas = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$index = $entity->getPath();
|
||||
|
||||
if (!isset($datas[$index])) {
|
||||
$datas[$index] = 0;
|
||||
}
|
||||
|
||||
$datas[$index] += $entity->getViews();
|
||||
}
|
||||
|
||||
arsort($datas, SORT_NUMERIC);
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
public function getReferers(): array
|
||||
{
|
||||
$entities = $this->getEntities('referer');
|
||||
$this->reload = false;
|
||||
|
||||
$datas = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$index = parse_url($entity->getUri(), PHP_URL_HOST);
|
||||
|
||||
if (!isset($datas[$index])) {
|
||||
$datas[$index] = [
|
||||
'views' => 0,
|
||||
'uris' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$datas[$index]['views'] += $entity->getViews();
|
||||
|
||||
$path = parse_url($entity->getUri(), PHP_URL_PATH);
|
||||
|
||||
if (!isset($datas[$index]['uris'][$path])) {
|
||||
$datas[$index]['uris'][$path] = 0;
|
||||
}
|
||||
|
||||
$datas[$index]['uris'][$path] += $entity->getViews();
|
||||
}
|
||||
|
||||
uasort($datas, function($a, $b) {
|
||||
if ($a['views'] > $b['views']) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ($a['views'] < $b['views']) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
public function setDateRange(?\DateTime $from, ?\DateTime $to): self
|
||||
{
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
$this->reload = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setNode(?Node $node): self
|
||||
{
|
||||
$this->node = $node;
|
||||
$this->reload = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function getEntities(string $type): array
|
||||
{
|
||||
if ('view' === $type) {
|
||||
$query = $this->viewQuery->create();
|
||||
} elseif ('referer' === $type) {
|
||||
$query = $this->refererQuery->create();
|
||||
} else {
|
||||
throw new \InvalidArgumentException('Invalid type');
|
||||
}
|
||||
|
||||
if (!$this->reload && isset($this->cache[$type])) {
|
||||
return $this->cache[$type];
|
||||
}
|
||||
|
||||
if (null !== $this->from) {
|
||||
$query
|
||||
->andWhere('.date >= :from')
|
||||
->setParameter(':from', $this->from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $this->to) {
|
||||
$query
|
||||
->andWhere('.date <= :to')
|
||||
->setParameter(':to', $this->to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $this->node) {
|
||||
$query
|
||||
->andWhere('.node = :node')
|
||||
->setParameter(':node', $this->node->getId())
|
||||
;
|
||||
}
|
||||
|
||||
$this->cache[$type] = $query->orderBy('.date')->find();
|
||||
|
||||
return $this->cache[$type];
|
||||
}
|
||||
}
|
38
core/Controller/Analytic/AnalyticController.php
Normal file
38
core/Controller/Analytic/AnalyticController.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Controller\Analytic;
|
||||
|
||||
use App\Core\Analytic\DateRangeAnalytic;
|
||||
use App\Core\Entity\Site\Node;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route("/admin/analytic")
|
||||
*/
|
||||
class AnalyticController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route("/stats/{node}/{range}", name="admin_analytic_stats")
|
||||
*/
|
||||
public function stats(Node $node, DateRangeAnalytic $analytic, string $range = '7days'): Response
|
||||
{
|
||||
if (!in_array($range, ['7days', '30days', '90days', '1year'])) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$analytic
|
||||
->setDateRange(new \DateTime('now - '.$range), new \DateTime())
|
||||
->setNode($node)
|
||||
;
|
||||
|
||||
return $this->render('@Core/analytic/stats.html.twig', [
|
||||
'range' => $range,
|
||||
'views' => $analytic->getViews(),
|
||||
'pathViews' => $analytic->getPathViews(),
|
||||
'referers' => $analytic->getReferers(),
|
||||
'node' => $node,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -21,11 +21,12 @@ use Symfony\Component\Form\FormError;
|
|||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
||||
/**
|
||||
* @Route("/admin/site/node")
|
||||
*/
|
||||
class NodeAdminController extends AdminController
|
||||
class NodeAdminController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route("/new/{node}", name="admin_site_node_new")
|
||||
|
@ -268,11 +269,6 @@ class NodeAdminController extends AdminController
|
|||
]);
|
||||
}
|
||||
|
||||
public function getSection(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function handlePageAssociation(
|
||||
string $pageAction,
|
||||
?Page $pageEntity,
|
||||
|
|
103
core/Entity/Analytic/Referer.php
Normal file
103
core/Entity/Analytic/Referer.php
Normal file
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Entity\Analytic;
|
||||
|
||||
use App\Core\Entity\Site\Node;
|
||||
use App\Repository\Entity\Analytic\NodeViewRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* @ORM\Entity(repositoryClass=ViewRepository::class)
|
||||
* @ORM\Table(name="analytic_referer")
|
||||
*/
|
||||
class Referer implements EntityInterface
|
||||
{
|
||||
/**
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity=Node::class, inversedBy="nodeViews")
|
||||
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
|
||||
*/
|
||||
protected $node;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="string", length=255)
|
||||
*/
|
||||
protected $uri;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="integer", options={"default"=0})
|
||||
*/
|
||||
protected $views = 0;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="date")
|
||||
*/
|
||||
protected $date;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getNode(): ?Node
|
||||
{
|
||||
return $this->node;
|
||||
}
|
||||
|
||||
public function setNode(?Node $node): self
|
||||
{
|
||||
$this->node = $node;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUri(): ?string
|
||||
{
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
public function setUri(string $uri): self
|
||||
{
|
||||
$this->uri = $uri;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getViews(): ?int
|
||||
{
|
||||
return $this->views;
|
||||
}
|
||||
|
||||
public function setViews(int $views): self
|
||||
{
|
||||
$this->views = $views;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addView(): self
|
||||
{
|
||||
++$this->views;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDate(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(\DateTimeInterface $date): self
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
103
core/Entity/Analytic/View.php
Normal file
103
core/Entity/Analytic/View.php
Normal file
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Entity\Analytic;
|
||||
|
||||
use App\Core\Entity\Site\Node;
|
||||
use App\Repository\Entity\Analytic\NodeViewRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* @ORM\Entity(repositoryClass=ViewRepository::class)
|
||||
* @ORM\Table(name="analytic_view")
|
||||
*/
|
||||
class View implements EntityInterface
|
||||
{
|
||||
/**
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @ORM\ManyToOne(targetEntity=Node::class, inversedBy="nodeViews")
|
||||
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
|
||||
*/
|
||||
protected $node;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="string", length=255)
|
||||
*/
|
||||
protected $path;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="integer", options={"default"=0})
|
||||
*/
|
||||
protected $views = 0;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="date")
|
||||
*/
|
||||
protected $date;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getNode(): ?Node
|
||||
{
|
||||
return $this->node;
|
||||
}
|
||||
|
||||
public function setNode(?Node $node): self
|
||||
{
|
||||
$this->node = $node;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPath(): ?string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function setPath(string $path): self
|
||||
{
|
||||
$this->path = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getViews(): ?int
|
||||
{
|
||||
return $this->views;
|
||||
}
|
||||
|
||||
public function setViews(int $views): self
|
||||
{
|
||||
$this->views = $views;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addView(): self
|
||||
{
|
||||
++$this->views;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDate(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(\DateTimeInterface $date): self
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ namespace App\Core\Entity\Site;
|
|||
|
||||
use App\Core\Doctrine\Timestampable;
|
||||
use App\Core\Entity\EntityInterface;
|
||||
use App\Core\Entity\NodeView;
|
||||
use App\Core\Entity\Site\Page\Page;
|
||||
use App\Core\Repository\Site\NodeRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
@ -11,6 +12,8 @@ use Doctrine\Common\Collections\Collection;
|
|||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Mapping\Annotation as Gedmo;
|
||||
use function Symfony\Component\String\u;
|
||||
use App\Core\Entity\Analytic\View;
|
||||
use App\Core\Entity\Analytic\Referer;
|
||||
|
||||
/**
|
||||
* @Gedmo\Tree(type="nested")
|
||||
|
@ -139,10 +142,27 @@ class Node implements EntityInterface
|
|||
*/
|
||||
protected $contentType;
|
||||
|
||||
/**
|
||||
* @ORM\Column(type="boolean", options={"default"=0})
|
||||
*/
|
||||
protected $enableAnalytics = false;
|
||||
|
||||
/**
|
||||
* @ORM\OneToMany(targetEntity=View::class, mappedBy="node")
|
||||
*/
|
||||
protected $analyticViews;
|
||||
|
||||
/**
|
||||
* @ORM\OneToMany(targetEntity=Referer::class, mappedBy="node")
|
||||
*/
|
||||
protected $analyticReferers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->children = new ArrayCollection();
|
||||
$this->aliasNodes = new ArrayCollection();
|
||||
$this->analyticViews = new ArrayCollection();
|
||||
$this->analyticReferers = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
|
@ -548,4 +568,76 @@ class Node implements EntityInterface
|
|||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnableAnalytics(): ?bool
|
||||
{
|
||||
return $this->enableAnalytics;
|
||||
}
|
||||
|
||||
public function setEnableAnalytics(bool $enableAnalytics): self
|
||||
{
|
||||
$this->enableAnalytics = $enableAnalytics;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|View[]
|
||||
*/
|
||||
public function getAnalyticViews(): Collection
|
||||
{
|
||||
return $this->analyticViews;
|
||||
}
|
||||
|
||||
public function addAnalyticView(View $view): self
|
||||
{
|
||||
if (!$this->analyticViews->contains($view)) {
|
||||
$this->analyticViews[] = $view;
|
||||
$view->setNode($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAnalyticView(View $view): self
|
||||
{
|
||||
if ($this->analyticViews->removeElement($view)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($view->getNode() === $this) {
|
||||
$view->setNode(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|Referer[]
|
||||
*/
|
||||
public function getAnalyticReferers(): Collection
|
||||
{
|
||||
return $this->analyticReferers;
|
||||
}
|
||||
|
||||
public function addAnalyticReferer(Referer $referer): self
|
||||
{
|
||||
if (!$this->analyticReferers->contains($referer)) {
|
||||
$this->analyticReferers[] = $referer;
|
||||
$referer->setNode($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAnalyticReferer(Referer $referer): self
|
||||
{
|
||||
if ($this->analyticReferers->removeElement($referer)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($referer->getNode() === $this) {
|
||||
$referer->setNode(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
134
core/EventListener/AnalyticListener.php
Normal file
134
core/EventListener/AnalyticListener.php
Normal file
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\EventListener;
|
||||
|
||||
use App\Core\Manager\EntityManager;
|
||||
use App\Core\Repository\Site\NodeRepositoryQuery;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use App\Core\Repository\Site\NodeRepository;
|
||||
use App\Core\Repository\Analytic\ViewRepositoryQuery;
|
||||
use App\Core\Factory\Analytic\ViewFactory;
|
||||
use App\Core\Entity\Site\Node;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use App\Core\Repository\Analytic\RefererRepositoryQuery;
|
||||
use App\Core\Factory\Analytic\RefererFactory;
|
||||
use App\Core\Entity\EntityInterface;
|
||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||
|
||||
/**
|
||||
* class AnalyticListener.
|
||||
*
|
||||
* @author Simon Vieille <simon@deblan.fr>
|
||||
*/
|
||||
class AnalyticListener
|
||||
{
|
||||
protected NodeRepository $nodeRepository;
|
||||
protected ViewRepositoryQuery $viewRepositoryQuery;
|
||||
protected ViewFactory $viewFactory;
|
||||
protected RefererRepositoryQuery $refererRepositoryQuery;
|
||||
protected RefererFactory $refererFactory;
|
||||
protected EntityManager $manager;
|
||||
protected CrawlerDetect $crawlerDetect;
|
||||
protected Request $request;
|
||||
protected Node $node;
|
||||
|
||||
public function __construct(
|
||||
NodeRepository $nodeRepository,
|
||||
ViewRepositoryQuery $viewRepositoryQuery,
|
||||
ViewFactory $viewFactory,
|
||||
RefererRepositoryQuery $refererRepositoryQuery,
|
||||
RefererFactory $refererFactory,
|
||||
EntityManager $manager
|
||||
) {
|
||||
$this->nodeRepository = $nodeRepository;
|
||||
$this->viewRepositoryQuery = $viewRepositoryQuery;
|
||||
$this->viewFactory = $viewFactory;
|
||||
$this->refererRepositoryQuery = $refererRepositoryQuery;
|
||||
$this->refererFactory = $refererFactory;
|
||||
$this->manager = $manager;
|
||||
$this->crawlerDetect = new CrawlerDetect();
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event)
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
if (!$request->attributes->has('_node')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->crawlerDetect->isCrawler($request->headers->get('user-agent'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$node = $this->nodeRepository->findOneBy([
|
||||
'id' => $request->attributes->get('_node'),
|
||||
'enableAnalytics' => true,
|
||||
]);
|
||||
|
||||
if (!$node) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->node = $node;
|
||||
$this->request = $request;
|
||||
|
||||
$this->createView();
|
||||
$this->createReferer();
|
||||
}
|
||||
|
||||
protected function createView()
|
||||
{
|
||||
$entity = $this->viewRepositoryQuery->create()
|
||||
->filterByRequest($this->request)
|
||||
->andWhere('.date=CURRENT_DATE()')
|
||||
->findOne()
|
||||
;
|
||||
|
||||
if (!$entity) {
|
||||
$entity = $this->viewFactory->create($this->node, $this->request->getPathInfo());
|
||||
}
|
||||
|
||||
$entity->addView();
|
||||
$this->save($entity);
|
||||
}
|
||||
|
||||
protected function createReferer()
|
||||
{
|
||||
if (!$this->request->headers->has('referer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$referer = $this->request->headers->get('referer');
|
||||
|
||||
if (!filter_var($referer, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity = $this->refererRepositoryQuery->create()
|
||||
->filterByRequest($this->request)
|
||||
->andWhere('.date=CURRENT_DATE()')
|
||||
->findOne()
|
||||
;
|
||||
|
||||
if (!$entity) {
|
||||
$entity = $this->refererFactory->create($this->node, $referer);
|
||||
}
|
||||
|
||||
$entity->addView();
|
||||
$this->save($entity);
|
||||
}
|
||||
|
||||
protected function save(EntityInterface $entity)
|
||||
{
|
||||
if ($entity->getId()) {
|
||||
$this->manager->update($entity);
|
||||
} else {
|
||||
$this->manager->create($entity);
|
||||
}
|
||||
}
|
||||
}
|
22
core/Factory/Analytic/RefererFactory.php
Normal file
22
core/Factory/Analytic/RefererFactory.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Factory\Analytic;
|
||||
|
||||
use App\Core\Entity\Analytic\Referer as Entity;
|
||||
use App\Core\Entity\Site\Node;
|
||||
use App\Core\Factory\FactoryInterface;
|
||||
|
||||
class RefererFactory implements FactoryInterface
|
||||
{
|
||||
public function create(Node $node, string $uri): Entity
|
||||
{
|
||||
$entity = new Entity();
|
||||
$entity
|
||||
->setNode($node)
|
||||
->setUri($uri)
|
||||
->setDate(new \DateTime())
|
||||
;
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
22
core/Factory/Analytic/ViewFactory.php
Normal file
22
core/Factory/Analytic/ViewFactory.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Factory\Analytic;
|
||||
|
||||
use App\Core\Entity\Analytic\View as Entity;
|
||||
use App\Core\Entity\Site\Node;
|
||||
use App\Core\Factory\FactoryInterface;
|
||||
|
||||
class ViewFactory implements FactoryInterface
|
||||
{
|
||||
public function create(Node $node, string $path): Entity
|
||||
{
|
||||
$entity = new Entity();
|
||||
$entity
|
||||
->setNode($node)
|
||||
->setPath($path)
|
||||
->setDate(new \DateTime())
|
||||
;
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
|
@ -60,6 +60,19 @@ class NodeType extends AbstractType
|
|||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'enableAnalytics',
|
||||
CheckboxType::class,
|
||||
[
|
||||
'label' => 'Enable analytics',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
],
|
||||
'constraints' => [
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'code',
|
||||
TextType::class,
|
||||
|
|
21
core/Repository/Analytic/RefererRepository.php
Normal file
21
core/Repository/Analytic/RefererRepository.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Repository\Analytic;
|
||||
|
||||
use App\Core\Entity\Analytic\Referer;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @method Referer|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method Referer|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method Referer[] findAll()
|
||||
* @method Referer[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class RefererRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Referer::class);
|
||||
}
|
||||
}
|
28
core/Repository/Analytic/RefererRepositoryQuery.php
Normal file
28
core/Repository/Analytic/RefererRepositoryQuery.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Repository\Analytic;
|
||||
|
||||
use App\Core\Repository\Analytic\RefererRepository as Repository;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use App\Core\Repository\RepositoryQuery;
|
||||
|
||||
class RefererRepositoryQuery extends RepositoryQuery
|
||||
{
|
||||
public function __construct(Repository $repository, PaginatorInterface $paginator)
|
||||
{
|
||||
parent::__construct($repository, 'n', $paginator);
|
||||
}
|
||||
|
||||
public function filterByRequest(Request $request)
|
||||
{
|
||||
return $this
|
||||
->andWhere('.node = :node')
|
||||
->andWhere('.uri = :uri')
|
||||
->setParameters([
|
||||
':node' => $request->attributes->get('_node'),
|
||||
':uri' => $request->headers->get('referer'),
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
21
core/Repository/Analytic/ViewRepository.php
Normal file
21
core/Repository/Analytic/ViewRepository.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Repository\Analytic;
|
||||
|
||||
use App\Core\Entity\Analytic\View;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @method View|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method View|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method View[] findAll()
|
||||
* @method View[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class ViewRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, View::class);
|
||||
}
|
||||
}
|
28
core/Repository/Analytic/ViewRepositoryQuery.php
Normal file
28
core/Repository/Analytic/ViewRepositoryQuery.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Repository\Analytic;
|
||||
|
||||
use App\Core\Repository\Analytic\ViewRepository as Repository;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use App\Core\Repository\RepositoryQuery;
|
||||
|
||||
class ViewRepositoryQuery extends RepositoryQuery
|
||||
{
|
||||
public function __construct(Repository $repository, PaginatorInterface $paginator)
|
||||
{
|
||||
parent::__construct($repository, 'n', $paginator);
|
||||
}
|
||||
|
||||
public function filterByRequest(Request $request)
|
||||
{
|
||||
return $this
|
||||
->andWhere('.node = :node')
|
||||
->andWhere('.path = :path')
|
||||
->setParameters([
|
||||
':node' => $request->attributes->get('_node'),
|
||||
':path' => $request->getPathInfo(),
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
|
@ -39,6 +39,14 @@
|
|||
"Never": "Jamais"
|
||||
"URL": "URL"
|
||||
"Disable URL": "Désactiver l'URL"
|
||||
"Enable analytics": "Activer les analyses"
|
||||
"Analytics": "Analyses"
|
||||
"Referers": "Référents"
|
||||
"Views": "Vues"
|
||||
"Last 7 days": "Les 7 derniers jours"
|
||||
"Last 30 days": "Les 30 derniers jours"
|
||||
"Last 90 days": "Les 90 derniers jours"
|
||||
"Last year": "L'année passée"
|
||||
"Controller": "Contrôleur"
|
||||
"Leave blank for automatic generation": "Laisser vide pour une génération automatique"
|
||||
"Leave blank to use the default one. Example: App\\Controller\\FooController::barAction": "Laisser vide pour utiliser celui par défaut. Exemple : App\\Controller\\FooController::barAction"
|
||||
|
|
133
core/Resources/views/analytic/stats.html.twig
Normal file
133
core/Resources/views/analytic/stats.html.twig
Normal file
|
@ -0,0 +1,133 @@
|
|||
<div class="modal-dialog modal-dialog-large">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ 'Analytics'|trans }}
|
||||
</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if range == '7days' %}active{% endif %}" data-modal="{{ path('admin_analytic_stats', {node: node.id, range: '7days'}) }}" href="#">
|
||||
{{ 'Last 7 days'|trans }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if range == '30days' %}active{% endif %}" data-modal="{{ path('admin_analytic_stats', {node: node.id, range: '30days'}) }}" href="#">
|
||||
{{ 'Last 30 days'|trans }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if range == '90days' %}active{% endif %}" data-modal="{{ path('admin_analytic_stats', {node: node.id, range: '90days'}) }}" href="#">
|
||||
{{ 'Last 90 days'|trans }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if range == '1year' %}active{% endif %}" data-modal="{{ path('admin_analytic_stats', {node: node.id, range: '1year'}) }}" href="#">
|
||||
{{ 'Last year'|trans }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% set values = [] %}
|
||||
{% for item in views %}{% set values = values|merge([item]) %}{% endfor %}
|
||||
|
||||
<div class="tab-content pt-4">
|
||||
<div id="analytic-chart-container">
|
||||
<canvas id="analytic-chart" data-label="{{ 'Views'|trans }}" data-labels="{{ views|keys|json_encode }}" data-values="{{ values|json_encode }}"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="pl-md-3">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'Path'|trans }}</th>
|
||||
<th width="100" class="text-right">{{ 'Views'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for path, views in pathViews %}
|
||||
<tr>
|
||||
<td>{{ path }}</td>
|
||||
<td class="text-right">{{ views }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="col-12 text-center p-4 text-black-50" colspan="2">
|
||||
<div class="display-1">
|
||||
<span class="fa fa-search"></span>
|
||||
</div>
|
||||
<div class="display-5 mt-3">
|
||||
{{ 'No result'|trans }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="pl-md-3">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'Referers'|trans }}</th>
|
||||
<th width="100" class="text-right">{{ 'Views'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for referer, info in referers %}
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<details>
|
||||
<summary>
|
||||
<div class="float-right">
|
||||
{{ info.views }}
|
||||
</div>
|
||||
|
||||
{{ referer }}
|
||||
</summary>
|
||||
|
||||
{% for path, views in info.uris %}
|
||||
<div class="pl-3">
|
||||
<div class="float-right">
|
||||
{{ info.views }}
|
||||
</div>
|
||||
|
||||
{{ path }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="col-12 text-center p-4 text-black-50" colspan="2">
|
||||
<div class="display-1">
|
||||
<span class="fa fa-search"></span>
|
||||
</div>
|
||||
<div class="display-5 mt-3">
|
||||
{{ 'No result'|trans }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ 'Close'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -205,6 +205,7 @@
|
|||
</div>
|
||||
|
||||
{{ form_row(form.disableUrl) }}
|
||||
{{ form_row(form.enableAnalytics) }}
|
||||
{{ form_row(form.code) }}
|
||||
{{ form_row(form.contentType) }}
|
||||
{{ form_row(form.controller) }}
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
{% set move = path('admin_site_node_move', {entity: node.id}) %}
|
||||
{% set edit = path('admin_site_node_edit', {entity: node.id}) %}
|
||||
{% set new = path('admin_site_node_new', {node: node.id}) %}
|
||||
{% set analytics = path('admin_analytic_stats', {node: node.id}) %}
|
||||
|
||||
<div class="list-group-item" id="node-{{ node.id }}">
|
||||
<div class="row">
|
||||
|
@ -147,6 +148,12 @@
|
|||
{% endif %}
|
||||
</button>
|
||||
|
||||
{% if node.enableAnalytics %}
|
||||
<button data-modal="{{ analytics }}" class="btn btn-sm btn-light border border-dark mr-1 mb-1">
|
||||
<span data-modal="{{ analytics }}" class="fa fa-chart-bar"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button data-modal="{{ move }}" class="btn btn-sm btn-dark mr-1 mb-1">
|
||||
<span data-modal="{{ move }}" class="fa fa-arrows-alt"></span>
|
||||
</button>
|
||||
|
@ -181,6 +188,12 @@
|
|||
{% endif %}
|
||||
</button>
|
||||
|
||||
{% if node.enableAnalytics %}
|
||||
<button data-modal="{{ analytics }}" class="dropdown-item">
|
||||
{{ 'Analytics'|trans }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button data-modal="{{ move }}" class="dropdown-item">
|
||||
{{ 'Move'|trans }}
|
||||
</button>
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Core\Site;
|
||||
|
||||
use App\Repository\Site\NodeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* @ORM\Entity(repositoryClass=NodeRepository::class)
|
||||
*/
|
||||
class Node
|
||||
{
|
||||
/**
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(type="integer")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
}
|
0
migrations/.gitignore
vendored
0
migrations/.gitignore
vendored
|
@ -32,6 +32,7 @@
|
|||
"@fortawesome/fontawesome-free": "^5.11.2",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^4.3.1",
|
||||
"chart.js": "^3.7.1",
|
||||
"choices.js": "^9.0.1",
|
||||
"flag-icon-css": "^3.5.0",
|
||||
"jquery": "^3.6.0",
|
||||
|
|
|
@ -129,6 +129,9 @@
|
|||
"imagine/imagine": {
|
||||
"version": "1.2.4"
|
||||
},
|
||||
"jaybizzle/crawler-detect": {
|
||||
"version": "v1.2.110"
|
||||
},
|
||||
"khanamiryan/qrcode-detector-decoder": {
|
||||
"version": "1.0.4"
|
||||
},
|
||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -1819,6 +1819,11 @@ chalk@^4.0.0:
|
|||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chart.js@^3.7.1:
|
||||
version "3.7.1"
|
||||
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada"
|
||||
integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==
|
||||
|
||||
choices.js@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/choices.js/-/choices.js-9.0.1.tgz#745fb29af8670428fdc0bf1cc9dfaa404e9d0510"
|
||||
|
@ -2048,9 +2053,9 @@ core-js-compat@^3.8.1, core-js-compat@^3.9.0:
|
|||
semver "7.0.0"
|
||||
|
||||
core-js@^3.0.0:
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.9.1.tgz#cec8de593db8eb2a85ffb0dbdeb312cb6e5460ae"
|
||||
integrity sha512-gSjRvzkxQc1zjM/5paAmL4idJBFzuJoo+jDjF1tStYFMV2ERfD02HhahhCGXUyHxQRG4yFKVSdO6g62eoRMcDg==
|
||||
version "3.21.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94"
|
||||
integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==
|
||||
|
||||
core-util-is@~1.0.0:
|
||||
version "1.0.2"
|
||||
|
|
Loading…
Reference in a new issue