Merge branch 'master' into feature_posts

This commit is contained in:
Alexey Skobkin 2015-10-25 21:25:19 +03:00
commit 41960f21c7
31 changed files with 1127 additions and 311 deletions

View File

@ -18,6 +18,7 @@ class AppKernel extends Kernel
new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
new Misd\GuzzleBundle\MisdGuzzleBundle(),
new Ob\HighchartsBundle\ObHighchartsBundle(),
new Skobkin\Bundle\PointToolsBundle\SkobkinPointToolsBundle(),
);

View File

@ -0,0 +1,34 @@
<?php
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Deletes user login unique index. Temporary fix for user renaming.
*/
class Version20151001210600 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('DROP INDEX users.uniq_338adfc4aa08cb10');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('CREATE UNIQUE INDEX uniq_338adfc4aa08cb10 ON users.users (login)');
}
}

View File

@ -26,6 +26,7 @@
<ul class="nav navbar-nav">
<li><a href="{{ path('index') }}"><span class="glyphicon glyphicon-home"></span> {{ 'Main'|trans }}</a></li>
<li><a href="{{ path('users_top') }}"><span class="glyphicon glyphicon-stats"></span> {{ 'Top'|trans }}</a></li>
<li><a href="{{ path('events_last') }}"><span class="glyphicon glyphicon-th-list"></span> {{ 'Last'|trans }}</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="https://bitbucket.org/skobkin/point-tools/issues?status=new&status=open" target="_blank"><span class="glyphicon glyphicon-envelope"></span> {{ 'Report a bug'|trans }}</a></li>
@ -57,4 +58,5 @@
</p>
</div>
</div>
{% include 'counters.html.twig' %}
{% endblock %}

View File

@ -0,0 +1,28 @@
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function (d, w, c) {
(w[c] = w[c] || []).push(function() {
try {
w.yaCounter32849312 = new Ya.Metrika({
id:32849312,
clickmap:true,
trackLinks:true,
accurateTrackBounce:true
});
} catch(e) { }
});
var n = d.getElementsByTagName("script")[0],
s = d.createElement("script"),
f = function () { n.parentNode.insertBefore(s, n); };
s.type = "text/javascript";
s.async = true;
s.src = "https://mc.yandex.ru/metrika/watch.js";
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} else { f(); }
})(document, window, "yandex_metrika_callbacks");
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/32849312" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->

View File

@ -638,20 +638,20 @@ class SymfonyRequirements extends RequirementCollection
}
$this->addRecommendation(
class_exists('Locale'),
extension_loaded('intl'),
'intl extension should be available',
'Install and enable the <strong>intl</strong> extension (used for validators).'
);
if (class_exists('Collator')) {
if (extension_loaded('intl')) {
// in some WAMP server installations, new Collator() returns null
$this->addRecommendation(
null !== new Collator('fr_FR'),
'intl extension should be correctly configured',
'The intl extension does not behave properly. This problem is typical on PHP 5.3.X x64 WIN builds.'
);
}
if (class_exists('Locale')) {
// check for compatible ICU versions (only done when you have the intl extension)
if (defined('INTL_ICU_VERSION')) {
$version = INTL_ICU_VERSION;
} else {
@ -670,6 +670,14 @@ class SymfonyRequirements extends RequirementCollection
'intl ICU version should be at least 4+',
'Upgrade your <strong>intl</strong> extension with a newer ICU version (4+).'
);
$this->addPhpIniRecommendation(
'intl.error_level',
create_function('$cfgValue', 'return (int) $cfgValue === 0;'),
true,
'intl.error_level should be 0 in php.ini',
'Set "<strong>intl.error_level</strong>" to "<strong>0</strong>" in php.ini<a href="#phpini">*</a> to inhibit the messages when an error occurs in ICU functions.'
);
}
$accelerator =

View File

@ -42,9 +42,9 @@ foreach ($symfonyRequirements->getRecommendations() as $req) {
}
if ($checkPassed) {
echo_block('success', 'OK', 'Your system is ready to run Symfony2 projects', true);
echo_block('success', 'OK', 'Your system is ready to run Symfony2 projects');
} else {
echo_block('error', 'ERROR', 'Your system is not ready to run Symfony2 projects', true);
echo_block('error', 'ERROR', 'Your system is not ready to run Symfony2 projects');
echo_title('Fix the following mandatory requirements', 'red');

View File

@ -8,7 +8,7 @@
},
"require": {
"php": ">=5.3.3",
"symfony/symfony": "2.7.x-dev",
"symfony/symfony": "2.7.*",
"doctrine/orm": "~2.2,>=2.2.3,<2.5",
"doctrine/dbal": "<2.5",
"doctrine/doctrine-bundle": "~1.4",
@ -20,8 +20,8 @@
"sensio/framework-extra-bundle": "~3.0,>=3.0.2",
"incenteev/composer-parameter-handler": "~2.0",
"misd/guzzle-bundle": "~1.0",
"doctrine/migrations": "1.0.*@dev",
"doctrine/doctrine-migrations-bundle": "2.1.*@dev"
"ob/highcharts-bundle": "^1.2",
"doctrine/doctrine-migrations-bundle": "^1.0"
},
"require-dev": {
"sensio/generator-bundle": "~2.3"

497
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -128,7 +128,7 @@ class UpdateSubscriptionsCommand extends ContainerAwareCommand
}
// @todo move to the config
usleep(200000);
usleep(500000);
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Skobkin\Bundle\PointToolsBundle\Entity\SubscriptionEvent;
use Skobkin\Bundle\PointToolsBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class ApiController extends Controller
{
/**
* Returns last user subscribers log
*
* @param $login
* @ParamConverter("user", class="SkobkinPointToolsBundle:User")
* @return Response
*/
public function lastUserSubscribersByIdAction(User $user)
{
$qb = $this->getDoctrine()->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->createQueryBuilder('se');
$qb
->select(['se', 'sub'])
->innerJoin('se.subscriber', 'sub')
->where($qb->expr()->eq('se.author', ':author'))
->orderBy('se.date', 'desc')
->setParameter('author', $user)
->setMaxResults(20)
;
$data = [];
/** @var SubscriptionEvent $event */
foreach ($qb->getQuery()->getResult() as $event) {
$data[] = [
'user' => $event->getSubscriber()->getLogin(),
'action' => $event->getAction(),
'datetime' => $event->getDate()->format('d.m.Y H:i:s'),
];
}
return new JsonResponse($data);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Controller;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class EventsController extends Controller
{
public function lastAction()
{
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
return $this->render('SkobkinPointToolsBundle:Events:last.html.twig', [
'last_events' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getLastSubscriptionEvents(20),
]);
}
}

View File

@ -13,40 +13,12 @@ class MainController extends Controller
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
/** @var QueryBuilder $qb */
$qb = $em->getRepository('SkobkinPointToolsBundle:User')->createQueryBuilder('u');
// All users in the system count
$usersCount = $qb->select('COUNT(u)')->getQuery()->getSingleScalarResult();
$qb = $em->getRepository('SkobkinPointToolsBundle:Subscription')->createQueryBuilder('s');
// Service subscribers count
$subscribersCount = $qb
->select('COUNT(s)')
->innerJoin('s.author', 'a')
->where('a.login = :login')
->setParameter('login', $this->container->getParameter('point_login'))
->getQuery()->getSingleScalarResult()
;
$qb = $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->createQueryBuilder('se');
$now = new \DateTime();
$eventsCount = $qb
->select('COUNT(se)')
->where('se.date > :time')
->setParameter('time', $now->sub(new \DateInterval('PT24H')))
->getQuery()->getSingleScalarResult()
;
return $this->render('SkobkinPointToolsBundle:Main:index.html.twig', [
'users_count' => $usersCount,
'subscribers_count' => $subscribersCount,
'events_count' => $eventsCount,
'users_count' => $em->getRepository('SkobkinPointToolsBundle:User')->getUsersCount(),
'subscribers_count' => $em->getRepository('SkobkinPointToolsBundle:Subscription')->getUserSubscribersCountById($this->container->getParameter('point_id')),
'events_count' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getLastDayEventsCount(),
'service_login' => $this->container->getParameter('point_login'),
'last_events' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getLastSubscriptionEvents(10),
]);
}
}

View File

@ -2,12 +2,12 @@
namespace Skobkin\Bundle\PointToolsBundle\Controller;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\EntityManager;
use Skobkin\Bundle\PointToolsBundle\Entity\TopUserDTO;
use Skobkin\Bundle\PointToolsBundle\Entity\User;
use Skobkin\Bundle\PointToolsBundle\Service\UserApi;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Ob\HighchartsBundle\Highcharts\Highchart;
use Symfony\Component\HttpFoundation\Request;
class UserController extends Controller
@ -17,16 +17,11 @@ class UserController extends Controller
*/
public function showAction($login)
{
/** @var QueryBuilder $qb */
$qb = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:User')->createQueryBuilder('u');
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
$user = $qb
->select('u')
->where('LOWER(u.login) = LOWER(:login)')
->setMaxResults(1)
->setParameter('login', $login)
->getQuery()->getOneOrNullResult()
;
/** @var User $user */
$user = $em->getRepository('SkobkinPointToolsBundle:User')->findUserByLogin($login);
if (!$user) {
throw $this->createNotFoundException('User ' . $login . ' not found.');
@ -34,56 +29,23 @@ class UserController extends Controller
$userApi = $this->container->get('skobkin_point_tools.api_user');
$qb = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:User')->createQueryBuilder('u');
$subscribers = $qb
->select('u')
->innerJoin('u.subscriptions', 's')
->where('s.author = :author')
->orderBy('u.login', 'asc')
->setParameter('author', $user->getId())
->getQuery()->getResult()
;
$qb = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->createQueryBuilder('se');
$subscriptionsEvents = $qb
->select()
->where('se.author = :author')
->orderBy('se.date', 'desc')
->setMaxResults(10)
->setParameter('author', $user)
->getQuery()->getResult()
;
return $this->render('SkobkinPointToolsBundle:User:show.html.twig', [
'user' => $user,
'subscribers' => $subscribers,
'log' => $subscriptionsEvents,
'subscribers' => $em->getRepository('SkobkinPointToolsBundle:User')->findUserSubscribersById($user->getId()),
'log' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getUserLastSubscribersEventsById($user, 10),
'avatar_url' => $userApi->getAvatarUrl($user, UserApi::AVATAR_SIZE_LARGE),
]);
}
public function topAction()
{
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
$topUsers = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:User')->getTopUsers();
/** @var QueryBuilder $qb */
$qb = $em->getRepository('SkobkinPointToolsBundle:Subscription')->createQueryBuilder('s');
/** @var TopUserDTO[] $topUsers */
$topUsers = $qb
->select(['COUNT(s.subscriber) as cnt', 'NEW SkobkinPointToolsBundle:TopUserDTO(a.login, COUNT(s.subscriber))'])
->innerJoin('s.author', 'a')
->orderBy('cnt', 'desc')
->groupBy('a.id')
->setMaxResults(30)
->getQuery()->getResult()
;
$topChart = $this->createTopUsersGraph($topUsers);
return $this->render('@SkobkinPointTools/User/top.html.twig', [
'top_users' => $topUsers
'top_users' => $topUsers,
'top_chart' => $topChart,
]);
}
@ -99,4 +61,47 @@ class UserController extends Controller
}
return $this->redirectToRoute('user_show', ['login' => $login]);
}
/**
* @param TopUserDTO[] $topUsers
* @return Highchart
*/
private function createTopUsersGraph(array $topUsers = [])
{
$translator = $this->container->get('translator');
$chartData = [
'titles' => [],
'subscribers' => [],
];
// Preparing chart data
foreach ($topUsers as $user) {
$chartData['titles'][] = $user->login;
$chartData['subscribers'][] = $user->subscribersCount;
}
// Chart
$series = [[
'name' => $translator->trans('Subscribers'),
'data' => $chartData['subscribers'],
]];
// Initializing chart
$ob = new Highchart();
$ob->chart->renderTo('top-chart');
$ob->chart->type('bar');
$ob->title->text($translator->trans('Top users'));
$ob->xAxis->title(['text' => null]);
$ob->xAxis->categories($chartData['titles']);
$ob->yAxis->title(['text' => $translator->trans('amount')]);
$ob->plotOptions->bar([
'dataLabels' => [
'enabled' => true
]
]);
$ob->series($series);
return $ob;
}
}

View File

@ -7,10 +7,10 @@ use Doctrine\ORM\Mapping as ORM;
/**
* Subscription
*
* @ORM\Table(name="subscriptions.subscriptions", uniqueConstraints={
* @ORM\Table(name="subscriptions.subscriptions", schema="subscriptions", uniqueConstraints={
* @ORM\UniqueConstraint(name="subscription_unique", columns={"author_id", "subscriber_id"})}
* )
* @ORM\Entity
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\SubscriptionRepository")
*/
class Subscription
{

View File

@ -7,12 +7,12 @@ use Doctrine\ORM\Mapping as ORM;
/**
* SubscriptionEvent
*
* @ORM\Table(name="subscriptions.log", indexes={
* @ORM\Table(name="subscriptions.log", schema="subscriptions", indexes={
* @ORM\Index(name="author_idx", columns={"author_id"}),
* @ORM\Index(name="subscriber_idx", columns={"subscriber_id"}),
* @ORM\Index(name="date_idx", columns={"date"})
* })
* @ORM\Entity
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\SubscriptionEventRepository")
* @ORM\HasLifecycleCallbacks
*/
class SubscriptionEvent

View File

@ -0,0 +1,75 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Entity;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
class SubscriptionEventRepository extends EntityRepository
{
/**
* @return integer
*/
public function getLastDayEventsCount()
{
$qb = $this->createQueryBuilder('se');
$now = new \DateTime();
return $qb
->select('COUNT(se)')
->where('se.date > :time')
->setParameter('time', $now->sub(new \DateInterval('PT24H')))
->getQuery()->getSingleScalarResult()
;
}
/**
* @param User $user
* @param integer $limit
* @return SubscriptionEvent[]
*/
public function getUserLastSubscribersEventsById(User $user, $limit)
{
if (!is_int($limit)) {
throw new \InvalidArgumentException('$limit must be an integer');
}
$qb = $this->createQueryBuilder('se');
return $qb
->select(['se', 's'])
->join('se.subscriber', 's')
->where('se.author = :author')
->orderBy('se.date', 'desc')
->setMaxResults($limit)
->setParameter('author', $user)
->getQuery()->getResult()
;
}
/**
* Get last $limit subscriptions
*
* @param integer $limit
* @return SubscriptionEvent[]
*/
public function getLastSubscriptionEvents($limit)
{
if (!is_int($limit)) {
throw new \InvalidArgumentException('$limit must be an integer');
}
$qb = $this->createQueryBuilder('se');
return $qb
->select()
->orderBy('se.date', 'desc')
->setMaxResults($limit)
->getQuery()
->setFetchMode('SkobkinPointToolsBundle:SubscriptionEvent', 'author', ClassMetadata::FETCH_EAGER)
->setFetchMode('SkobkinPointToolsBundle:SubscriptionEvent', 'subscriber', ClassMetadata::FETCH_EAGER)
->getResult()
;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Entity;
use Doctrine\ORM\EntityRepository;
class SubscriptionRepository extends EntityRepository
{
/**
* @param integer $id
* @return integer
*/
public function getUserSubscribersCountById($id)
{
if (!is_int($id)) {
throw new \InvalidArgumentException('$id must be an integer');
}
$qb = $this->createQueryBuilder('s');
return $qb
->select('COUNT(s)')
->innerJoin('s.author', 'a')
->where('a.id = :id')
->setParameter('id', $id)
->getQuery()->getSingleScalarResult()
;
}
}

View File

@ -8,8 +8,8 @@ use Doctrine\ORM\Mapping as ORM;
/**
* User
*
* @ORM\Table(name="users.users")
* @ORM\Entity
* @ORM\Table(name="users.users", schema="users")
* @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\UserRepository")
* @ORM\HasLifecycleCallbacks
*/
class User
@ -25,7 +25,7 @@ class User
/**
* @var string
*
* @ORM\Column(name="login", type="string", length=255, nullable=false, unique=true)
* @ORM\Column(name="login", type="string", length=255, nullable=false)
*/
private $login;
@ -71,8 +71,17 @@ class User
private $newSubscriberEvents;
public function __construct()
/**
* @param int $id
* @param string $login
* @param string $name
*/
public function __construct($id = null, $login = null, $name = null)
{
$this->id = $id;
$this->login = $login;
$this->name = $name;
$this->subscribers = new ArrayCollection();
$this->subscriptions = new ArrayCollection();
$this->newSubscriberEvents = new ArrayCollection();

View File

@ -0,0 +1,82 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Entity;
use Doctrine\ORM\EntityRepository;
class UserRepository extends EntityRepository
{
/**
* Case-insensitive user search
*
* @param string $login
* @return User[]
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function findUserByLogin($login)
{
$qb = $this->createQueryBuilder('u');
return $qb
->select('u')
->where('LOWER(u.login) = LOWER(:login)')
->setMaxResults(1)
->setParameter('login', $login)
->getQuery()->getOneOrNullResult()
;
}
/**
* @return integer
*/
public function getUsersCount()
{
$qb = $this->createQueryBuilder('u');
return $qb->select('COUNT(u)')->getQuery()->getSingleScalarResult();
}
/**
* @param integer $id
* @return User[]
*/
public function findUserSubscribersById($id)
{
if (!is_int($id)) {
throw new \InvalidArgumentException('$id must be an integer');
}
$qb = $this->createQueryBuilder('u');
return $qb
->select('u')
->innerJoin('u.subscriptions', 's')
->where('s.author = :author')
->orderBy('u.login', 'asc')
->setParameter('author', $id)
->getQuery()->getResult()
;
}
/**
* @return TopUserDTO[]
*/
public function getTopUsers($limit = 30)
{
if (!is_int($limit)) {
throw new \InvalidArgumentException('$limit must be an integer');
}
// TODO: refactor query
$qb = $this->getEntityManager()->getRepository('SkobkinPointToolsBundle:Subscription')->createQueryBuilder('s');
return $qb
->select(['COUNT(s.subscriber) as cnt', 'NEW SkobkinPointToolsBundle:TopUserDTO(a.login, COUNT(s.subscriber))'])
->innerJoin('s.author', 'a')
->orderBy('cnt', 'desc')
->groupBy('a.id')
->setMaxResults($limit)
->getQuery()->getResult()
;
}
}

View File

@ -0,0 +1,5 @@
last_user_events:
path: /user/id/{id}/events/subscribers
defaults: { _controller: SkobkinPointToolsBundle:Api:lastUserSubscribersById, _format: json }
requirements:
id: \d+

View File

@ -15,4 +15,12 @@ user_show:
users_top:
path: /top
defaults: { _controller: SkobkinPointToolsBundle:User:top }
defaults: { _controller: SkobkinPointToolsBundle:User:top }
events_last:
path: /last
defaults: { _controller: SkobkinPointToolsBundle:Events:last }
skobkin_point_tools:
resource: "@SkobkinPointToolsBundle/Resources/config/api/routing.yml"
prefix: /api/v1

View File

@ -5,6 +5,7 @@ Toggle navigation: Переключить навигацию
Main: Главная
Top: Топ
Report a bug: Сообщить об ошибке
Last: Последнее
# Подвал
Source code: Исходный код
@ -13,6 +14,9 @@ Source code: Исходный код
All users: Всего пользователей
Subscribed users: Подписчиков сервиса
24 hours events: Событий за сутки
Author: Автор
Subscriber: Подписчик
Last events: Последние события
Username: Имя пользователя
Search: Поиск
@ -29,4 +33,5 @@ No log data found: Лог отсутствует
# Топ пользователей
Top users: Популярные пользователи
Subscribers count: Подписчиков
Subscribers count: Подписчиков
amount: Количество

View File

@ -0,0 +1,55 @@
{% extends "::base.html.twig" %}
{% block content %}
{# TODO classes #}
<div class="last-subscriptions-log">
{% if last_events|length > 0 %}
<div class="panel-group" id="accordion-log">
<div class="panel panel-default">
<div class="panel-heading" id="heading-subscriptions-log">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion-log" aria-expanded="true" href="#collapse-log">
<span class="glyphicon glyphicon-collapse-down"></span> {{ 'Last events'|trans }}
</a>
</h4>
</div>
<div id="collapse-log" class="panel-collapse collapse in" aria-labelledby="heading-subscriptions-log">
<div class="panel-body">
<table class="table table-striped">
<thead>
<tr>
<td>{{ 'Subscriber'|trans }}</td>
<td>{{ 'Author'|trans }}</td>
<td>{{ 'Action'|trans }}</td>
<td>{{ 'Date'|trans }}</td>
</tr>
</thead>
<tbody>
{% for event in last_events %}
<tr>
<td>
<a href="{{ url('user_show', {login: event.subscriber.login}) }}">@{{ event.subscriber.login }}</a>
</td>
<td>
<a href="{{ url('user_show', {login: event.author.login}) }}">@{{ event.author.login }}</a>
</td>
<td>
<span class="glyphicon {% if event.action == 'subscribe' %}glyphicon-plus{% elseif event.action == 'unsubscribe' %}glyphicon-minus{% endif %}"></span>
</td>
<td>
{# Use DateTime helper: https://sonata-project.org/bundles/intl/master/doc/reference/datetime.html #}
{{ event.date|date('d F Y H:i:s') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-warning" role="alert">{{ 'No log data found'|trans }}</div>
{% endif %}
</div>
{% endblock %}

View File

@ -26,7 +26,7 @@
</div>
<div class="row">
<div class="col-xs-8 col-sm-3"><span class="glyphicon glyphicon-list"></span> {{ '24 hours events'|trans }}</div>
<div class="col-xs-4 col-sm-2">{{ events_count }}</div>
<div class="col-xs-4 col-sm-2"><a href="{{ url('events_last') }}">{{ events_count }}</a></div>
</div>
</div>
{% endblock %}

View File

@ -15,7 +15,7 @@
<div class="panel-heading" id="heading-subscribers">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion-subscribers" aria-expanded="false" href="#collapse-subscribers">
<span class="glyphicon glyphicon-collapse-down"></span> {{ 'Subscribers'|trans }}
<span class="glyphicon glyphicon-collapse-down"></span> {{ 'Subscribers'|trans }} ({{ subscribers|length }})
</a>
</h4>
</div>
@ -66,7 +66,7 @@
<span class="glyphicon {% if event.action == 'subscribe' %}glyphicon-plus{% elseif event.action == 'unsubscribe' %}glyphicon-minus{% endif %}"></span>
</td>
<td>
{# Use DateTime helper: https://sonata-project.org/bundles/intl/master/doc/reference/datetime.html #}
{# @todo Use DateTime helper: https://sonata-project.org/bundles/intl/master/doc/reference/datetime.html #}
{{ event.date|date('d F Y H:i:s') }}
</td>
</tr>

View File

@ -2,23 +2,17 @@
{% block header_title %}Top @ Point Tools{% endblock %}
{% block content %}
<h1>{{ 'Top users'|trans }}</h1>
<table class="table table-striped">
<thead>
<tr>
<td>{{ 'User'|trans }}</td>
<td>{{ 'Subscribers count'|trans }}</td>
</tr>
</thead>
<tbody>
{% for user in top_users %}
<tr>
<td><a href="{{ url('user_show', {login: user.login}) }}">@{{ user.login }}</a></td>
<td>{{ user.subscribersCount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% block head_js %}
{{ parent() }}
<script src="//yastatic.net/jquery/2.1.4/jquery.min.js"></script>
<script src="//code.highcharts.com/4.0.1/highcharts.js"></script>
<script src="//code.highcharts.com/4.0.1/modules/exporting.js"></script>
{% endblock %}
{% block content %}
<script type="text/javascript">
{{ chart(top_chart) }}
</script>
<div id="top-chart" style="min-width: 400px; height: 600px; margin: 0 auto;"></div>
{% endblock %}

View File

@ -0,0 +1,9 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\Exceptions;
class ApiException extends \Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\Exceptions;
class InvalidResponseException extends ApiException
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\Exceptions;
class SubscriptionManagerException extends \Exception
{
}

View File

@ -0,0 +1,47 @@
<?php
namespace Skobkin\Bundle\PointToolsBundle\Service\Exceptions;
class UserNotFoundException extends ApiException
{
/**
* @var int
*/
protected $userId;
/**
* @var string
*/
protected $login;
/**
* {@inheritdoc}
* @param int $userId
*/
public function __construct($message = 'User not found', $code = 0, \Exception $previous = null, $userId = null, $login = null)
{
parent::__construct($message, $code, $previous);
$this->userId = $userId;
$this->login = $login;
}
/**
* Returns ID of user which was not found
*
* @return int
*/
public function getUserId()
{
return $this->userId;
}
/**
* @return string
*/
public function getLogin()
{
return $this->login;
}
}

View File

@ -5,8 +5,13 @@ namespace Skobkin\Bundle\PointToolsBundle\Service;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Guzzle\Http\Exception\ClientErrorResponseException;
use Guzzle\Service\Client;
use Skobkin\Bundle\PointToolsBundle\Entity\User;
use Skobkin\Bundle\PointToolsBundle\Service\Exceptions\ApiException;
use Skobkin\Bundle\PointToolsBundle\Service\Exceptions\InvalidResponseException;
use Skobkin\Bundle\PointToolsBundle\Service\Exceptions\UserNotFoundException;
use Symfony\Component\HttpFoundation\Response;
/**
* Basic Point.im user API functions from /api/user/*
@ -20,19 +25,25 @@ class UserApi extends AbstractApi
/**
* @var string Base URL for user avatars
*/
protected $avatarsBaseUrl = 'point.im/avatar/';
protected $avatarsBaseUrl = '//point.im/avatar/';
/**
* @var EntityManager
*/
protected $em;
/**
* @var EntityRepository
*/
protected $userRepository;
public function __construct(Client $httpClient, $https = true, $baseUrl = null, EntityManagerInterface $entityManager)
{
parent::__construct($httpClient, $https, $baseUrl);
$this->em = $entityManager;
$this->userRepository = $this->em->getRepository('SkobkinPointToolsBundle:User');
}
public function getName()
@ -45,21 +56,33 @@ class UserApi extends AbstractApi
*
* @param string $login
* @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/
public function getUserSubscribersByLogin($login)
{
$usersList = $this->getGetRequestData('/api/user/' . $login . '/subscribers', [], true);
try {
$usersList = $this->getGetRequestData('/api/user/'.urlencode($login).'/subscribers', [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, null, $login);
} else {
throw $e;
}
}
$users = $this->getUsersFromList($usersList);
return $users;
return $this->getUsersFromList($usersList);
}
/**
* Get user subscribers by user id
*
* @param int $id
* @param $id
* @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/
public function getUserSubscribersById($id)
{
@ -67,57 +90,222 @@ class UserApi extends AbstractApi
throw new \InvalidArgumentException('$id must be an integer');
}
$usersList = $this->getGetRequestData('/api/user/id/' . (int) $id . '/subscribers', [], true);
try {
$usersList = $this->getGetRequestData('/api/user/id/'.(int) $id.'/subscribers', [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, $id);
} else {
throw $e;
}
}
$users = $this->getUsersFromList($usersList);
return $users;
return $this->getUsersFromList($usersList);
}
/**
* Get user subscriptions by user login
*
* @param string $login
* @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/
public function getUserSubscriptionsByLogin($login)
{
try {
$usersList = $this->getGetRequestData('/api/user/'.urlencode($login).'/subscriptions', [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, null, $login);
} else {
throw $e;
}
}
return $this->getUsersFromList($usersList);
}
/**
* Get user subscriptions by user id
*
* @param $id
* @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/
public function getUserSubscriptionsById($id)
{
if (!is_numeric($id)) {
throw new \InvalidArgumentException('$id must be an integer');
}
try {
$usersList = $this->getGetRequestData('/api/user/id/'.(int) $id.'/subscriptions', [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, $id);
} else {
throw $e;
}
}
return $this->getUsersFromList($usersList);
}
/**
* Get single user by login
*
* @param string $login
* @return User
* @throws UserNotFoundException
* @throws ClientErrorResponseException
*/
public function getUserByLogin($login)
{
try {
$userInfo = $this->getGetRequestData('/api/user/login/'.urlencode($login), [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, null, $login);
} else {
throw $e;
}
}
return $this->getUserFromUserInfo($userInfo);
}
/**
* Get single user by id
*
* @param $id
* @return User
* @throws UserNotFoundException
* @throws ClientErrorResponseException
*/
public function getUserById($id)
{
if (!is_numeric($id)) {
throw new \InvalidArgumentException('$id must be an integer');
}
try {
$userInfo = $this->getGetRequestData('/api/user/id/'.(int) $id, [], true);
} catch (ClientErrorResponseException $e) {
if (Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode()) {
throw new UserNotFoundException('User not found', 0, $e, $id);
} else {
throw $e;
}
}
return $this->getUserFromUserInfo($userInfo);
}
/**
* Finds and updates or create new user from API response data
*
* @param array $userInfo
* @return User
* @throws ApiException
* @throws InvalidResponseException
*/
public function getUserFromUserInfo(array $userInfo)
{
if (!is_array($userInfo)) {
throw new \InvalidArgumentException('$userInfo must be an array');
}
// @todo Return ID existance check when @ap-Codkelden will fix this API behaviour
if (array_key_exists('id', $userInfo) && array_key_exists('login', $userInfo) && array_key_exists('name', $userInfo) && is_numeric($userInfo['id'])) {
/** @var User $user */
if (null === ($user = $this->userRepository->find($userInfo['id']))) {
// Creating new user
$user = new User($userInfo['id']);
$this->em->persist($user);
}
// Updating data
$user
->setLogin($userInfo['login'])
->setName($userInfo['name'])
;
try {
$this->em->flush($user);
} catch (\Exception $e) {
throw new ApiException(sprintf('Error while flushing changes for [%d] %s: %s', $user->getId(), $user->getLogin(), $e->getMessage()), 0, $e);
}
return $user;
}
throw new InvalidResponseException('Invalid API response. Mandatory fields do not exist.');
}
/**
* Get array of User objects from API response containing user list
*
* @param array $users
* @return User[]
* @throws ApiException
* @throws InvalidResponseException
*/
private function getUsersFromList(array $users = [])
{
/** @var EntityRepository $userRepo */
$userRepo = $this->em->getRepository('SkobkinPointToolsBundle:User');
if (!is_array($users)) {
throw new \InvalidArgumentException('$users must be an array');
}
/** @var User[] $resultUsers */
$resultUsers = [];
foreach ($users as $userData) {
if (array_key_exists('id', $userData) && array_key_exists('login', $userData) && array_key_exists('name', $userData) && is_numeric($userData['id'])) {
foreach ($users as $userInfo) {
if (array_key_exists('id', $userInfo) && array_key_exists('login', $userInfo) && array_key_exists('name', $userInfo) && is_numeric($userInfo['id'])) {
// @todo Optimize with prehashed id's list
$user = $userRepo->find($userData['id']);
if (!$user) {
$user = new User();
$user->setId((int) $userData['id']);
if (null === ($user = $this->userRepository->find($userInfo['id']))) {
$user = new User((int) $userInfo['id']);
$this->em->persist($user);
}
// Updating data
if ($user->getLogin() !== $userData['login']) {
$user->setLogin($userData['login']);
}
if ($user->getName() !== $userData['name']) {
$user->setName($userData['name']);
$user
->setLogin($userInfo['login'])
->setName($userInfo['name'])
;
try {
$this->em->flush($user);
} catch (\Exception $e) {
throw new ApiException(sprintf('Error while flushing changes for [%d] %s: %s', $user->getId(), $user->getLogin(), $e->getMessage()), 0, $e);
}
$resultUsers[] = $user;
} else {
throw new InvalidResponseException('Invalid API response. Mandatory fields do not exist.');
}
}
$this->em->flush();
return $resultUsers;
}
/**
* @param $login
* Creates avatar with specified size URL for user
*
* @param User $user
* @param int $size
* @return string
*/
public function getAvatarUrl(User $user, $size)
{
return ($this->useHttps ? 'https://' : 'http://') . $this->avatarsBaseUrl . $user->getLogin() . '/' . $size;
if (!in_array($size, [self::AVATAR_SIZE_SMALL, self::AVATAR_SIZE_MEDIUM, self::AVATAR_SIZE_LARGE], true)) {
throw new \InvalidArgumentException('Avatar size must be one of restricted variants. See UserApi class AVATAR_SIZE_* constants.');
}
return $this->avatarsBaseUrl.urlencode($user->getLogin()).'/'.$size;
}
}