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 Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(), new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
new Misd\GuzzleBundle\MisdGuzzleBundle(), new Misd\GuzzleBundle\MisdGuzzleBundle(),
new Ob\HighchartsBundle\ObHighchartsBundle(),
new Skobkin\Bundle\PointToolsBundle\SkobkinPointToolsBundle(), 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"> <ul class="nav navbar-nav">
<li><a href="{{ path('index') }}"><span class="glyphicon glyphicon-home"></span> {{ 'Main'|trans }}</a></li> <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('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>
<ul class="nav navbar-nav navbar-right"> <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> <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> </p>
</div> </div>
</div> </div>
{% include 'counters.html.twig' %}
{% endblock %} {% 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( $this->addRecommendation(
class_exists('Locale'), extension_loaded('intl'),
'intl extension should be available', 'intl extension should be available',
'Install and enable the <strong>intl</strong> extension (used for validators).' '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( $this->addRecommendation(
null !== new Collator('fr_FR'), null !== new Collator('fr_FR'),
'intl extension should be correctly configured', '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.' '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')) { if (defined('INTL_ICU_VERSION')) {
$version = INTL_ICU_VERSION; $version = INTL_ICU_VERSION;
} else { } else {
@ -670,6 +670,14 @@ class SymfonyRequirements extends RequirementCollection
'intl ICU version should be at least 4+', 'intl ICU version should be at least 4+',
'Upgrade your <strong>intl</strong> extension with a newer ICU version (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 = $accelerator =

View File

@ -42,9 +42,9 @@ foreach ($symfonyRequirements->getRecommendations() as $req) {
} }
if ($checkPassed) { 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 { } 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'); echo_title('Fix the following mandatory requirements', 'red');

View File

@ -8,7 +8,7 @@
}, },
"require": { "require": {
"php": ">=5.3.3", "php": ">=5.3.3",
"symfony/symfony": "2.7.x-dev", "symfony/symfony": "2.7.*",
"doctrine/orm": "~2.2,>=2.2.3,<2.5", "doctrine/orm": "~2.2,>=2.2.3,<2.5",
"doctrine/dbal": "<2.5", "doctrine/dbal": "<2.5",
"doctrine/doctrine-bundle": "~1.4", "doctrine/doctrine-bundle": "~1.4",
@ -20,8 +20,8 @@
"sensio/framework-extra-bundle": "~3.0,>=3.0.2", "sensio/framework-extra-bundle": "~3.0,>=3.0.2",
"incenteev/composer-parameter-handler": "~2.0", "incenteev/composer-parameter-handler": "~2.0",
"misd/guzzle-bundle": "~1.0", "misd/guzzle-bundle": "~1.0",
"doctrine/migrations": "1.0.*@dev", "ob/highcharts-bundle": "^1.2",
"doctrine/doctrine-migrations-bundle": "2.1.*@dev" "doctrine/doctrine-migrations-bundle": "^1.0"
}, },
"require-dev": { "require-dev": {
"sensio/generator-bundle": "~2.3" "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 // @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 */ /** @var EntityManager $em */
$em = $this->getDoctrine()->getManager(); $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', [ return $this->render('SkobkinPointToolsBundle:Main:index.html.twig', [
'users_count' => $usersCount, 'users_count' => $em->getRepository('SkobkinPointToolsBundle:User')->getUsersCount(),
'subscribers_count' => $subscribersCount, 'subscribers_count' => $em->getRepository('SkobkinPointToolsBundle:Subscription')->getUserSubscribersCountById($this->container->getParameter('point_id')),
'events_count' => $eventsCount, 'events_count' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getLastDayEventsCount(),
'service_login' => $this->container->getParameter('point_login'), '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; namespace Skobkin\Bundle\PointToolsBundle\Controller;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Skobkin\Bundle\PointToolsBundle\Entity\TopUserDTO; use Skobkin\Bundle\PointToolsBundle\Entity\TopUserDTO;
use Skobkin\Bundle\PointToolsBundle\Entity\User; use Skobkin\Bundle\PointToolsBundle\Entity\User;
use Skobkin\Bundle\PointToolsBundle\Service\UserApi; use Skobkin\Bundle\PointToolsBundle\Service\UserApi;
use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Ob\HighchartsBundle\Highcharts\Highchart;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class UserController extends Controller class UserController extends Controller
@ -17,16 +17,11 @@ class UserController extends Controller
*/ */
public function showAction($login) public function showAction($login)
{ {
/** @var QueryBuilder $qb */ /** @var EntityManager $em */
$qb = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:User')->createQueryBuilder('u'); $em = $this->getDoctrine()->getManager();
$user = $qb /** @var User $user */
->select('u') $user = $em->getRepository('SkobkinPointToolsBundle:User')->findUserByLogin($login);
->where('LOWER(u.login) = LOWER(:login)')
->setMaxResults(1)
->setParameter('login', $login)
->getQuery()->getOneOrNullResult()
;
if (!$user) { if (!$user) {
throw $this->createNotFoundException('User ' . $login . ' not found.'); throw $this->createNotFoundException('User ' . $login . ' not found.');
@ -34,56 +29,23 @@ class UserController extends Controller
$userApi = $this->container->get('skobkin_point_tools.api_user'); $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', [ return $this->render('SkobkinPointToolsBundle:User:show.html.twig', [
'user' => $user, 'user' => $user,
'subscribers' => $subscribers, 'subscribers' => $em->getRepository('SkobkinPointToolsBundle:User')->findUserSubscribersById($user->getId()),
'log' => $subscriptionsEvents, 'log' => $em->getRepository('SkobkinPointToolsBundle:SubscriptionEvent')->getUserLastSubscribersEventsById($user, 10),
'avatar_url' => $userApi->getAvatarUrl($user, UserApi::AVATAR_SIZE_LARGE), 'avatar_url' => $userApi->getAvatarUrl($user, UserApi::AVATAR_SIZE_LARGE),
]); ]);
} }
public function topAction() public function topAction()
{ {
/** @var EntityManager $em */ $topUsers = $this->getDoctrine()->getManager()->getRepository('SkobkinPointToolsBundle:User')->getTopUsers();
$em = $this->getDoctrine()->getManager();
/** @var QueryBuilder $qb */ $topChart = $this->createTopUsersGraph($topUsers);
$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()
;
return $this->render('@SkobkinPointTools/User/top.html.twig', [ 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]); 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 * 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\UniqueConstraint(name="subscription_unique", columns={"author_id", "subscriber_id"})}
* ) * )
* @ORM\Entity * @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\SubscriptionRepository")
*/ */
class Subscription class Subscription
{ {

View File

@ -7,12 +7,12 @@ use Doctrine\ORM\Mapping as ORM;
/** /**
* SubscriptionEvent * 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="author_idx", columns={"author_id"}),
* @ORM\Index(name="subscriber_idx", columns={"subscriber_id"}), * @ORM\Index(name="subscriber_idx", columns={"subscriber_id"}),
* @ORM\Index(name="date_idx", columns={"date"}) * @ORM\Index(name="date_idx", columns={"date"})
* }) * })
* @ORM\Entity * @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\SubscriptionEventRepository")
* @ORM\HasLifecycleCallbacks * @ORM\HasLifecycleCallbacks
*/ */
class SubscriptionEvent 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 * User
* *
* @ORM\Table(name="users.users") * @ORM\Table(name="users.users", schema="users")
* @ORM\Entity * @ORM\Entity(repositoryClass="Skobkin\Bundle\PointToolsBundle\Entity\UserRepository")
* @ORM\HasLifecycleCallbacks * @ORM\HasLifecycleCallbacks
*/ */
class User class User
@ -25,7 +25,7 @@ class User
/** /**
* @var string * @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; private $login;
@ -71,8 +71,17 @@ class User
private $newSubscriberEvents; 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->subscribers = new ArrayCollection();
$this->subscriptions = new ArrayCollection(); $this->subscriptions = new ArrayCollection();
$this->newSubscriberEvents = 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

@ -16,3 +16,11 @@ user_show:
users_top: users_top:
path: /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: Главная Main: Главная
Top: Топ Top: Топ
Report a bug: Сообщить об ошибке Report a bug: Сообщить об ошибке
Last: Последнее
# Подвал # Подвал
Source code: Исходный код Source code: Исходный код
@ -13,6 +14,9 @@ Source code: Исходный код
All users: Всего пользователей All users: Всего пользователей
Subscribed users: Подписчиков сервиса Subscribed users: Подписчиков сервиса
24 hours events: Событий за сутки 24 hours events: Событий за сутки
Author: Автор
Subscriber: Подписчик
Last events: Последние события
Username: Имя пользователя Username: Имя пользователя
Search: Поиск Search: Поиск
@ -30,3 +34,4 @@ No log data found: Лог отсутствует
# Топ пользователей # Топ пользователей
Top users: Популярные пользователи 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>
<div class="row"> <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-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>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -15,7 +15,7 @@
<div class="panel-heading" id="heading-subscribers"> <div class="panel-heading" id="heading-subscribers">
<h4 class="panel-title"> <h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion-subscribers" aria-expanded="false" href="#collapse-subscribers"> <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> </a>
</h4> </h4>
</div> </div>
@ -66,7 +66,7 @@
<span class="glyphicon {% if event.action == 'subscribe' %}glyphicon-plus{% elseif event.action == 'unsubscribe' %}glyphicon-minus{% endif %}"></span> <span class="glyphicon {% if event.action == 'subscribe' %}glyphicon-plus{% elseif event.action == 'unsubscribe' %}glyphicon-minus{% endif %}"></span>
</td> </td>
<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') }} {{ event.date|date('d F Y H:i:s') }}
</td> </td>
</tr> </tr>

View File

@ -2,23 +2,17 @@
{% block header_title %}Top @ Point Tools{% endblock %} {% block header_title %}Top @ Point Tools{% endblock %}
{% block content %} {% block head_js %}
<h1>{{ 'Top users'|trans }}</h1> {{ parent() }}
<script src="//yastatic.net/jquery/2.1.4/jquery.min.js"></script>
<table class="table table-striped"> <script src="//code.highcharts.com/4.0.1/highcharts.js"></script>
<thead> <script src="//code.highcharts.com/4.0.1/modules/exporting.js"></script>
<tr> {% endblock %}
<td>{{ 'User'|trans }}</td>
<td>{{ 'Subscribers count'|trans }}</td> {% block content %}
</tr> <script type="text/javascript">
</thead> {{ chart(top_chart) }}
<tbody> </script>
{% for user in top_users %}
<tr> <div id="top-chart" style="min-width: 400px; height: 600px; margin: 0 auto;"></div>
<td><a href="{{ url('user_show', {login: user.login}) }}">@{{ user.login }}</a></td>
<td>{{ user.subscribersCount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %} {% 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\EntityManager;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Guzzle\Http\Exception\ClientErrorResponseException;
use Guzzle\Service\Client; use Guzzle\Service\Client;
use Skobkin\Bundle\PointToolsBundle\Entity\User; 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/* * Basic Point.im user API functions from /api/user/*
@ -20,19 +25,25 @@ class UserApi extends AbstractApi
/** /**
* @var string Base URL for user avatars * @var string Base URL for user avatars
*/ */
protected $avatarsBaseUrl = 'point.im/avatar/'; protected $avatarsBaseUrl = '//point.im/avatar/';
/** /**
* @var EntityManager * @var EntityManager
*/ */
protected $em; protected $em;
/**
* @var EntityRepository
*/
protected $userRepository;
public function __construct(Client $httpClient, $https = true, $baseUrl = null, EntityManagerInterface $entityManager) public function __construct(Client $httpClient, $https = true, $baseUrl = null, EntityManagerInterface $entityManager)
{ {
parent::__construct($httpClient, $https, $baseUrl); parent::__construct($httpClient, $https, $baseUrl);
$this->em = $entityManager; $this->em = $entityManager;
$this->userRepository = $this->em->getRepository('SkobkinPointToolsBundle:User');
} }
public function getName() public function getName()
@ -45,21 +56,33 @@ class UserApi extends AbstractApi
* *
* @param string $login * @param string $login
* @return User[] * @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/ */
public function getUserSubscribersByLogin($login) 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 $this->getUsersFromList($usersList);
return $users;
} }
/** /**
* Get user subscribers by user id * Get user subscribers by user id
* *
* @param int $id * @param $id
* @return User[] * @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/ */
public function getUserSubscribersById($id) public function getUserSubscribersById($id)
{ {
@ -67,57 +90,222 @@ class UserApi extends AbstractApi
throw new \InvalidArgumentException('$id must be an integer'); 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 $this->getUsersFromList($usersList);
return $users;
} }
/** /**
* Get user subscriptions by user login
*
* @param string $login
* @return User[] * @return User[]
* @throws ApiException
* @throws InvalidResponseException
* @throws UserNotFoundException
*/ */
private function getUsersFromList(array $users = []) public function getUserSubscriptionsByLogin($login)
{ {
/** @var EntityRepository $userRepo */ try {
$userRepo = $this->em->getRepository('SkobkinPointToolsBundle:User'); $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;
}
}
$resultUsers = []; return $this->getUsersFromList($usersList);
}
foreach ($users as $userData) { /**
if (array_key_exists('id', $userData) && array_key_exists('login', $userData) && array_key_exists('name', $userData) && is_numeric($userData['id'])) { * 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');
}
// @todo Optimize with prehashed id's list try {
$user = $userRepo->find($userData['id']); $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;
}
}
if (!$user) { return $this->getUsersFromList($usersList);
$user = new User(); }
$user->setId((int) $userData['id']);
/**
* 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); $this->em->persist($user);
} }
// Updating data // Updating data
if ($user->getLogin() !== $userData['login']) { $user
$user->setLogin($userData['login']); ->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);
} }
if ($user->getName() !== $userData['name']) {
$user->setName($userData['name']); 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 = [])
{
if (!is_array($users)) {
throw new \InvalidArgumentException('$users must be an array');
}
/** @var User[] $resultUsers */
$resultUsers = [];
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
if (null === ($user = $this->userRepository->find($userInfo['id']))) {
$user = new User((int) $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);
} }
$resultUsers[] = $user; $resultUsers[] = $user;
} else {
throw new InvalidResponseException('Invalid API response. Mandatory fields do not exist.');
} }
} }
$this->em->flush();
return $resultUsers; 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) 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;
} }
} }