Browse Source

Add mark as favorite feature

Jordi Boggiano 12 years ago
parent
commit
3cbeb02498

+ 8 - 0
README.md

@@ -3,6 +3,14 @@ Packagist
 
 Package Repository Website for Composer, see the [about page](http://packagist.org/about) on [packagist.org](http://packagist.org/) for more.
 
+Requirements
+------------
+
+- MySQL for the main data store
+- Redis for some functionality (favorites, download statistics)
+- Solr for search
+- git/svn/hg depending on which repositories you want to support
+
 Installation
 ------------
 

+ 2 - 1
app/Resources/FOSUserBundle/views/Profile/show.html.twig

@@ -10,7 +10,8 @@
         {% if not user.githubId %}
         <p><a href="{{ hwi_oauth_login_url('github') }}">Connect your github account</a></p>
         {% endif %}
-        <p><a href="{{ path('user_profile', {'name':app.user.username}) }}">View your public profile</a></p>
+        <p><a href="{{ path('user_favorites', {name: app.user.username}) }}">View your favorites</a></p>
+        <p><a href="{{ path('user_profile', {name: app.user.username}) }}">View your public profile</a></p>
 
         {% if app.user.apiToken %}
             <h1>Your API Token</h1>

+ 66 - 16
src/Packagist/WebBundle/Controller/UserController.php

@@ -15,12 +15,18 @@ namespace Packagist\WebBundle\Controller;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
 use Pagerfanta\Pagerfanta;
 use Pagerfanta\Adapter\DoctrineORMAdapter;
 use FOS\UserBundle\Model\UserInterface;
 use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Packagist\WebBundle\Entity\User;
+use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Model\RedisAdapter;
 
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>
@@ -30,17 +36,10 @@ class UserController extends Controller
     /**
      * @Template()
      * @Route("/users/{name}/packages/", name="user_packages")
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
      */
-    public function packagesAction(Request $req, $name)
+    public function packagesAction(Request $req, User $user)
     {
-        $user = $this->getDoctrine()
-            ->getRepository('PackagistWebBundle:User')
-            ->findOneByUsername($name);
-
-        if (!$user) {
-            throw new NotFoundHttpException('The requested user, '.$name.', could not be found.');
-        }
-
         return array('packages' => $this->getUserPackages($req, $user), 'user' => $user);
     }
 
@@ -61,18 +60,69 @@ class UserController extends Controller
     /**
      * @Template()
      * @Route("/users/{name}/", name="user_profile")
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
      */
-    public function profileAction(Request $req, $name)
+    public function profileAction(Request $req, User $user)
     {
-        $user = $this->getDoctrine()
-            ->getRepository('PackagistWebBundle:User')
-            ->findOneByUsername($name);
+        return array('packages' => $this->getUserPackages($req, $user), 'user' => $user);
+    }
 
-        if (!$user) {
-            throw new NotFoundHttpException('The requested user, '.$name.', could not be found.');
+    /**
+     * @Template()
+     * @Route("/users/{name}/favorites/", name="user_favorites")
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     */
+    public function favoritesAction(Request $req, User $user)
+    {
+        $paginator = new Pagerfanta(
+            new RedisAdapter($this->get('packagist.favorite_manager'), $user, 'getFavorites', 'getFavoriteCount')
+        );
+
+        return array('packages' => $paginator, 'user' => $user);
+    }
+
+    /**
+     * @Route("/users/{name}/favorites", name="user_add_fav", defaults={"_format" = "json"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     * @Method({"POST"})
+     */
+    public function postFavoriteAction(User $user)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You can only change your own favorites');
         }
 
-        return array('packages' => $this->getUserPackages($req, $user), 'user' => $user);
+        $req = $this->getRequest();
+
+        $package = $req->request->get('package');
+        try {
+            $package = $this->getDoctrine()
+                ->getRepository('PackagistWebBundle:Package')
+                ->findOneByName($package);
+        } catch (NoResultException $e) {
+            throw new NotFoundHttpException('The given package "'.$package.'" was not found.');
+        }
+
+        $this->get('packagist.favorite_manager')->markFavorite($user, $package);
+
+        return new Response('{"status": "success"}', 201);
+    }
+
+    /**
+     * @Route("/users/{name}/favorites/{package}", name="user_remove_fav", defaults={"_format" = "json"}, requirements={"package"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     * @ParamConverter("package", options={"mapping": {"package": "name"}})
+     * @Method({"DELETE"})
+     */
+    public function deleteFavoriteAction(User $user, Package $package)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You can only change your own favorites');
+        }
+
+        $this->get('packagist.favorite_manager')->removeFavorite($user, $package);
+
+        return new Response('{"status": "success"}', 204);
     }
 
     protected function getUserPackages($req, $user)

+ 3 - 0
src/Packagist/WebBundle/Controller/WebController.php

@@ -324,6 +324,9 @@ class WebController extends Controller
                 'monthly' => $counts[1] ?: 0,
                 'daily' => $counts[2] ?: 0,
             );
+            if ($this->getUser()) {
+                $data['is_favorite'] = $this->get('packagist.favorite_manager')->isMarked($this->getUser(), $package);
+            }
         } catch (\Exception $e) {
             $data['downloads'] = array(
                 'total' => 'N/A',

+ 79 - 0
src/Packagist/WebBundle/Model/FavoriteManager.php

@@ -0,0 +1,79 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Model;
+
+use FOS\UserBundle\Model\UserInterface;
+use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Entity\PackageRepository;
+use Packagist\WebBundle\Entity\UserRepository;
+use Predis\Client;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class FavoriteManager
+{
+    protected $redis;
+    protected $packageRepo;
+    protected $userRepo;
+
+    public function __construct(Client $redis, PackageRepository $packageRepo, UserRepository $userRepo)
+    {
+        $this->redis = $redis;
+        $this->packageRepo = $packageRepo;
+        $this->userRepo = $userRepo;
+    }
+
+    public function markFavorite(UserInterface $user, Package $package)
+    {
+        if (!$this->isMarked($user, $package)) {
+            $this->redis->zadd('pkg:'.$package->getId().':fav', time(), $user->getId());
+            $this->redis->zadd('usr:'.$user->getId().':fav', time(), $package->getId());
+        }
+    }
+
+    public function removeFavorite(UserInterface $user, Package $package)
+    {
+        $this->redis->zrem('pkg:'.$package->getId().':fav', $user->getId());
+        $this->redis->zrem('usr:'.$user->getId().':fav', $package->getId());
+    }
+
+    public function getFavorites(UserInterface $user, $limit = 0, $offset = 0)
+    {
+        $favoriteIds = $this->redis->zrevrange('usr:'.$user->getId().':fav', $offset, $offset + $limit - 1);
+
+        return $this->packageRepo->findById($favoriteIds);
+    }
+
+    public function getFavoriteCount(UserInterface $user)
+    {
+        return $this->redis->zcard('usr:'.$user->getId().':fav');
+    }
+
+    public function getFavers(Package $package, $offset = 0, $limit = 100)
+    {
+        $faverIds = $this->redis->zrevrange('pkg:'.$package->getId().':fav', $offset, $offset + $limit - 1);
+
+        return $this->userRepo->findById($faverIds);
+    }
+
+    public function getFaverCount(Package $package)
+    {
+        return $this->redis->zcard('pkg:'.$package->getId().':fav');
+    }
+
+    public function isMarked(UserInterface $user, Package $package)
+    {
+        return null !== $this->redis->zrank('usr:'.$user->getId().':fav', $package->getId());
+    }
+}

+ 50 - 0
src/Packagist/WebBundle/Model/RedisAdapter.php

@@ -0,0 +1,50 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Model;
+
+use Pagerfanta\Adapter\AdapterInterface;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class RedisAdapter implements AdapterInterface
+{
+    protected $model;
+    protected $instance;
+    protected $fetchMethod;
+    protected $countMethod;
+
+    public function __construct($model, $instance, $fetchMethod, $countMethod)
+    {
+        $this->model = $model;
+        $this->instance = $instance;
+        $this->fetchMethod = $fetchMethod;
+        $this->countMethod = $countMethod;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getNbResults()
+    {
+        return $this->model->{$this->countMethod}($this->instance);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getSlice($offset, $length)
+    {
+        return $this->model->{$this->fetchMethod}($this->instance, $length, $offset);
+    }
+}

+ 13 - 0
src/Packagist/WebBundle/Resources/config/services.yml

@@ -20,6 +20,12 @@ services:
         factory_method: getRepository
         arguments: ["PackagistWebBundle:User"]
 
+    packagist.package_repository:
+        class: Packagist\WebBundle\Entity\PackageRepository
+        factory_service: doctrine
+        factory_method: getRepository
+        arguments: ["PackagistWebBundle:Package"]
+
     packagist.package_updater:
         class: Packagist\WebBundle\Package\Updater
         arguments: [@doctrine]
@@ -48,3 +54,10 @@ services:
         class: Symfony\Component\Form\Form
         arguments:
             - 'packagist_oauth_user_registration'
+
+    packagist.favorite_manager:
+        class: Packagist\WebBundle\Model\FavoriteManager
+        arguments:
+            - @snc_redis.default_client
+            - @packagist.package_repository
+            - @packagist.user_repository

+ 11 - 1
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -589,7 +589,17 @@ form ul {
   margin-bottom: 10px;
 }
 
-.no-js .package .force-update {
+.package .mark-favorite {
+  width: 20px;
+  height: 20px;
+  display: inline-block;
+  background: black;
+}
+.package .mark-favorite.is-favorite {
+  background: green;
+}
+
+.no-js .package .force-update, .no-js .package .mark-favorite {
   display: none;
 }
 .package .action {

+ 24 - 0
src/Packagist/WebBundle/Resources/public/js/view.js

@@ -37,6 +37,30 @@
         });
         submit.addClass('loading');
     });
+    $('.package .mark-favorite').click(function (e) {
+        var options = {
+            dataType: 'json',
+            cache: false,
+            success: function (data) {
+                $(this).removeClass('loading').toggleClass('is-favorite');
+            },
+            context: this
+        };
+        e.preventDefault();
+        if ($(this).is('.loading')) {
+            return;
+        }
+        if ($(this).is('.is-favorite')) {
+            options.type = 'DELETE';
+            options.url = $(this).data('remove-url');
+        } else {
+            options.type = 'POST';
+            options.data = {"package": $(this).data('package')};
+            options.url = $(this).data('add-url');
+        }
+        $.ajax(options);
+        $(this).addClass('loading');
+    });
     $('.package .force-delete').submit(function (e) {
         e.preventDefault();
         if (confirm('Are you sure?')) {

+ 5 - 0
src/Packagist/WebBundle/Resources/views/User/favorites.html.twig

@@ -0,0 +1,5 @@
+{% extends "PackagistWebBundle:Web:list.html.twig" %}
+
+{% block content_title %}
+    <h1>{{ user.username }}'s favorite packages</h1>
+{% endblock %}

+ 3 - 0
src/Packagist/WebBundle/Resources/views/Web/viewPackage.html.twig

@@ -29,6 +29,9 @@
                 </form>
             {% endif %}
             <h1>
+                {% if is_favorite is defined %}
+                    <a class="mark-favorite{% if is_favorite %} is-favorite{% endif %}" data-remove-url="{{ path('user_remove_fav', {name: app.user.username, package: package.name}) }}" data-add-url="{{ path('user_add_fav', {name: app.user.username}) }}" data-package="{{ package.name }}"></a>
+                {% endif %}
                 <a href="{{ path("view_vendor", {"vendor": package.vendor}) }}">{{ package.vendor }}/</a>{{ package.packageName }}
             </h1>
             {% if version and version.tags|length %}