first draft

This commit is contained in:
Kevin Papst
2016-10-20 22:10:41 +02:00
parent 94a35ff914
commit 579b962c85
186 changed files with 30734 additions and 0 deletions

7
src/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* Kimai main application bundle.
*
* @see http://symfony.com/doc/current/cookbook/bundles/best_practices.html
* @see http://symfony.com/doc/current/best_practices/business-logic.html
*/
class AppBundle extends Bundle
{
}

View File

@@ -0,0 +1,222 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Controller\Admin;
use AppBundle\Entity\Post;
use AppBundle\Form\PostType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
* FIXME CAN BE REMOVED
*
* Controller used to manage blog contents in the backend.
*
* Please note that the application backend is developed manually for learning
* purposes. However, in your real Symfony application you should use any of the
* existing bundles that let you generate ready-to-use backends without effort.
* See http://knpbundles.com/keyword/admin
*
* @Route("/admin/post")
* @Security("has_role('ROLE_ADMIN')")
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class BlogController extends Controller
{
/**
* Lists all Post entities.
*
* This controller responds to two different routes with the same URL:
* * 'admin_post_index' is the route with a name that follows the same
* structure as the rest of the controllers of this class.
* * 'admin_index' is a nice shortcut to the backend homepage. This allows
* to create simpler links in the templates. Moreover, in the future we
* could move this annotation to any other controller while maintaining
* the route name and therefore, without breaking any existing link.
*
* @Route("/", name="admin_index")
* @Route("/", name="admin_post_index")
* @Method("GET")
*/
public function indexAction()
{
$entityManager = $this->getDoctrine()->getManager();
$posts = $entityManager->getRepository(Post::class)->findAll();
return $this->render('admin/blog/index.html.twig', ['posts' => $posts]);
}
/**
* Creates a new Post entity.
*
* @Route("/new", name="admin_post_new")
* @Method({"GET", "POST"})
*
* NOTE: the Method annotation is optional, but it's a recommended practice
* to constraint the HTTP methods each controller responds to (by default
* it responds to all methods).
*/
public function newAction(Request $request)
{
$post = new Post();
$post->setAuthorEmail($this->getUser()->getEmail());
// See http://symfony.com/doc/current/book/forms.html#submitting-forms-with-multiple-buttons
$form = $this->createForm(PostType::class, $post)
->add('saveAndCreateNew', SubmitType::class);
$form->handleRequest($request);
// the isSubmitted() method is completely optional because the other
// isValid() method already checks whether the form is submitted.
// However, we explicitly add it to improve code readability.
// See http://symfony.com/doc/current/best_practices/forms.html#handling-form-submits
if ($form->isSubmitted() && $form->isValid()) {
$post->setSlug($this->get('slugger')->slugify($post->getTitle()));
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($post);
$entityManager->flush();
// Flash messages are used to notify the user about the result of the
// actions. They are deleted automatically from the session as soon
// as they are accessed.
// See http://symfony.com/doc/current/book/controller.html#flash-messages
$this->addFlash('success', 'post.created_successfully');
if ($form->get('saveAndCreateNew')->isClicked()) {
return $this->redirectToRoute('admin_post_new');
}
return $this->redirectToRoute('admin_post_index');
}
return $this->render('admin/blog/new.html.twig', [
'post' => $post,
'form' => $form->createView(),
]);
}
/**
* Finds and displays a Post entity.
*
* @Route("/{id}", requirements={"id": "\d+"}, name="admin_post_show")
* @Method("GET")
*/
public function showAction(Post $post)
{
// This security check can also be performed:
// 1. Using an annotation: @Security("post.isAuthor(user)")
// 2. Using a "voter" (see http://symfony.com/doc/current/cookbook/security/voters_data_permission.html)
if (null === $this->getUser() || !$post->isAuthor($this->getUser())) {
throw $this->createAccessDeniedException('Posts can only be shown to their authors.');
}
$deleteForm = $this->createDeleteForm($post);
return $this->render('admin/blog/show.html.twig', [
'post' => $post,
'delete_form' => $deleteForm->createView(),
]);
}
/**
* Displays a form to edit an existing Post entity.
*
* @Route("/{id}/edit", requirements={"id": "\d+"}, name="admin_post_edit")
* @Method({"GET", "POST"})
*/
public function editAction(Post $post, Request $request)
{
if (null === $this->getUser() || !$post->isAuthor($this->getUser())) {
throw $this->createAccessDeniedException('Posts can only be edited by their authors.');
}
$entityManager = $this->getDoctrine()->getManager();
$editForm = $this->createForm(PostType::class, $post);
$deleteForm = $this->createDeleteForm($post);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$post->setSlug($this->get('slugger')->slugify($post->getTitle()));
$entityManager->flush();
$this->addFlash('success', 'post.updated_successfully');
return $this->redirectToRoute('admin_post_edit', ['id' => $post->getId()]);
}
return $this->render('admin/blog/edit.html.twig', [
'post' => $post,
'edit_form' => $editForm->createView(),
'delete_form' => $deleteForm->createView(),
]);
}
/**
* Deletes a Post entity.
*
* @Route("/{id}", name="admin_post_delete")
* @Method("DELETE")
* @Security("post.isAuthor(user)")
*
* The Security annotation value is an expression (if it evaluates to false,
* the authorization mechanism will prevent the user accessing this resource).
* The isAuthor() method is defined in the AppBundle\Entity\Post entity.
*/
public function deleteAction(Request $request, Post $post)
{
$form = $this->createDeleteForm($post);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($post);
$entityManager->flush();
$this->addFlash('success', 'post.deleted_successfully');
}
return $this->redirectToRoute('admin_post_index');
}
/**
* Creates a form to delete a Post entity by id.
*
* This is necessary because browsers don't support HTTP methods different
* from GET and POST. Since the controller that removes the blog posts expects
* a DELETE method, the trick is to create a simple form that *fakes* the
* HTTP DELETE method.
* See http://symfony.com/doc/current/cookbook/routing/method_parameters.html.
*
* @param Post $post The post object
*
* @return \Symfony\Component\Form\Form The form
*/
private function createDeleteForm(Post $post)
{
return $this->createFormBuilder()
->setAction($this->generateUrl('admin_post_delete', ['id' => $post->getId()]))
->setMethod('DELETE')
->getForm()
;
}
}

View File

@@ -0,0 +1,132 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Controller;
use AppBundle\Entity\Comment;
use AppBundle\Entity\Post;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use AppBundle\Form\CommentType;
/**
* FIXME CAN BE REMOVED
*
* Controller used to manage blog contents in the public part of the site.
*
* @Route("/blog")
* @Security("has_role('ROLE_USER')")
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class BlogController extends Controller
{
/**
* @Route("/", defaults={"page": 1}, name="blog_index")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="blog_index_paginated")
* @Method("GET")
* @Cache(smaxage="10")
*/
public function indexAction($page)
{
$posts = $this->getDoctrine()->getRepository(Post::class)->findLatest($page);
return $this->render('blog/index.html.twig', ['posts' => $posts]);
}
/**
* @Route("/posts/{slug}", name="blog_post")
* @Method("GET")
*
* NOTE: The $post controller argument is automatically injected by Symfony
* after performing a database query looking for a Post with the 'slug'
* value given in the route.
* See http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html
*/
public function postShowAction(Post $post)
{
// Symfony provides a function called 'dump()' which is an improved version
// of the 'var_dump()' function. It's useful to quickly debug the contents
// of any variable, but it's not available in the 'prod' environment to
// prevent any leak of sensitive information.
// This function can be used both in PHP files and Twig templates. The only
// requirement is to have enabled the DebugBundle.
if ('dev' === $this->getParameter('kernel.environment')) {
dump($post, $this->get('security.token_storage')->getToken()->getUser(), new \DateTime());
}
return $this->render('blog/post_show.html.twig', ['post' => $post]);
}
/**
* @Route("/comment/{postSlug}/new", name="comment_new")
* @Method("POST")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @ParamConverter("post", options={"mapping": {"postSlug": "slug"}})
*
* NOTE: The ParamConverter mapping is required because the route parameter
* (postSlug) doesn't match any of the Doctrine entity properties (slug).
* See http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html#doctrine-converter
*/
public function commentNewAction(Request $request, Post $post)
{
$form = $this->createForm(CommentType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var Comment $comment */
$comment = $form->getData();
$comment->setAuthorEmail($this->getUser()->getEmail());
$comment->setPost($post);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($comment);
$entityManager->flush();
return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
}
return $this->render('blog/comment_form_error.html.twig', [
'post' => $post,
'form' => $form->createView(),
]);
}
/**
* This controller is called directly via the render() function in the
* blog/post_show.html.twig template. That's why it's not needed to define
* a route name for it.
*
* The "id" of the Post is passed in and then turned into a Post object
* automatically by the ParamConverter.
*
* @param Post $post
*
* @return Response
*/
public function commentFormAction(Post $post)
{
$form = $this->createForm(CommentType::class);
return $this->render('blog/_comment_form.html.twig', [
'post' => $post,
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
/**
* Controller used to manage the application security.
* See http://symfony.com/doc/current/cookbook/security/form_login_setup.html.
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class SecurityController extends Controller
{
/**
* @Route("/login", name="security_login")
*/
public function loginAction()
{
$helper = $this->get('security.authentication_utils');
return $this->render('security/login.html.twig', [
// last username entered by the user (if any)
'last_username' => $helper->getLastUsername(),
// last authentication error (if any)
'error' => $helper->getLastAuthenticationError(),
]);
}
/**
* This is the route the user can use to logout.
*
* But, this will never be executed. Symfony will intercept this first
* and handle the logout automatically. See logout in app/config/security.yml
*
* @Route("/logout", name="security_logout")
*/
public function logoutAction()
{
throw new \Exception('This should never be reached!');
}
}

View File

@@ -0,0 +1,200 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\DataFixtures\ORM;
use AppBundle\Entity\User;
use AppBundle\Entity\Post;
use AppBundle\Entity\Comment;
use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests. Execute this command to load the data:
*
* $ php bin/console doctrine:fixtures:load
*
* See http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class LoadFixtures implements FixtureInterface, ContainerAwareInterface
{
/** @var ContainerInterface */
private $container;
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
// FIXME CAN BE REMOVED
$this->loadUsers($manager);
$this->loadPosts($manager);
}
private function loadUsers(ObjectManager $manager)
{
$passwordEncoder = $this->container->get('security.password_encoder');
$johnUser = new User();
$johnUser->setUsername('john_user');
$johnUser->setEmail('john_user@symfony.com');
$encodedPassword = $passwordEncoder->encodePassword($johnUser, 'kitten');
$johnUser->setPassword($encodedPassword);
$manager->persist($johnUser);
$annaAdmin = new User();
$annaAdmin->setUsername('anna_admin');
$annaAdmin->setEmail('anna_admin@symfony.com');
$annaAdmin->setRoles(['ROLE_ADMIN']);
$encodedPassword = $passwordEncoder->encodePassword($annaAdmin, 'kitten');
$annaAdmin->setPassword($encodedPassword);
$manager->persist($annaAdmin);
$manager->flush();
}
private function loadPosts(ObjectManager $manager)
{
foreach (range(1, 30) as $i) {
$post = new Post();
$post->setTitle($this->getRandomPostTitle());
$post->setSummary($this->getRandomPostSummary());
$post->setSlug($this->container->get('slugger')->slugify($post->getTitle()));
$post->setContent($this->getPostContent());
$post->setAuthorEmail('anna_admin@symfony.com');
$post->setPublishedAt(new \DateTime('now - '.$i.'days'));
foreach (range(1, 5) as $j) {
$comment = new Comment();
$comment->setAuthorEmail('john_user@symfony.com');
$comment->setPublishedAt(new \DateTime('now + '.($i + $j).'seconds'));
$comment->setContent($this->getRandomCommentContent());
$comment->setPost($post);
$manager->persist($comment);
$post->addComment($comment);
}
$manager->persist($post);
}
$manager->flush();
}
/**
* {@inheritdoc}
*/
public function setContainer(ContainerInterface $container = null)
{
$this->container = $container;
}
protected function getPostContent()
{
return <<<MARKDOWN
Lorem ipsum dolor sit amet consectetur adipisicing elit, sed do eiusmod tempor
incididunt ut labore et **dolore magna aliqua**: Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum.
* Ut enim ad minim veniam
* Quis nostrud exercitation *ullamco laboris*
* Nisi ut aliquip ex ea commodo consequat
Praesent id fermentum lorem. Ut est lorem, fringilla at accumsan nec, euismod at
nunc. Aenean mattis sollicitudin mattis. Nullam pulvinar vestibulum bibendum.
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos
himenaeos. Fusce nulla purus, gravida ac interdum ut, blandit eget ex. Duis a
luctus dolor.
Integer auctor massa maximus nulla scelerisque accumsan. *Aliquam ac malesuada*
ex. Pellentesque tortor magna, vulputate eu vulputate ut, venenatis ac lectus.
Praesent ut lacinia sem. Mauris a lectus eget felis mollis feugiat. Quisque
efficitur, mi ut semper pulvinar, urna urna blandit massa, eget tincidunt augue
nulla vitae est.
Ut posuere aliquet tincidunt. Aliquam erat volutpat. **Class aptent taciti**
sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi
arcu orci, gravida eget aliquam eu, suscipit et ante. Morbi vulputate metus vel
ipsum finibus, ut dapibus massa feugiat. Vestibulum vel lobortis libero. Sed
tincidunt tellus et viverra scelerisque. Pellentesque tincidunt cursus felis.
Sed in egestas erat.
Aliquam pulvinar interdum massa, vel ullamcorper ante consectetur eu. Vestibulum
lacinia ac enim vel placerat. Integer pulvinar magna nec dui malesuada, nec
congue nisl dictum. Donec mollis nisl tortor, at congue erat consequat a. Nam
tempus elit porta, blandit elit vel, viverra lorem. Sed sit amet tellus
tincidunt, faucibus nisl in, aliquet libero.
MARKDOWN;
}
protected function getPhrases()
{
return [
'Lorem ipsum dolor sit amet consectetur adipiscing elit',
'Pellentesque vitae velit ex',
'Mauris dapibus risus quis suscipit vulputate',
'Eros diam egestas libero eu vulputate risus',
'In hac habitasse platea dictumst',
'Morbi tempus commodo mattis',
'Ut suscipit posuere justo at vulputate',
'Ut eleifend mauris et risus ultrices egestas',
'Aliquam sodales odio id eleifend tristique',
'Urna nisl sollicitudin id varius orci quam id turpis',
'Nulla porta lobortis ligula vel egestas',
'Curabitur aliquam euismod dolor non ornare',
'Sed varius a risus eget aliquam',
'Nunc viverra elit ac laoreet suscipit',
'Pellentesque et sapien pulvinar consectetur',
];
}
protected function getRandomPhrase()
{
return $this->getRandomPostTitle();
}
private function getRandomPostTitle()
{
$titles = $this->getPhrases();
return $titles[array_rand($titles)];
}
private function getRandomPostSummary($maxLength = 255)
{
$phrases = $this->getPhrases();
$numPhrases = mt_rand(6, 12);
shuffle($phrases);
return substr(implode(' ', array_slice($phrases, 0, $numPhrases-1)), 0, $maxLength);
}
private function getRandomCommentContent()
{
$phrases = $this->getPhrases();
$numPhrases = mt_rand(2, 15);
shuffle($phrases);
return implode(' ', array_slice($phrases, 0, $numPhrases-1));
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Doctrine;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
/**
* Adds a prefix to every doctrine entity AKA database table
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TablePrefixSubscriber implements \Doctrine\Common\EventSubscriber
{
protected $prefix = '';
public function __construct($prefix)
{
$this->prefix = (string) $prefix;
}
public function getSubscribedEvents()
{
return array('loadClassMetadata');
}
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$classMetadata = $args->getClassMetadata();
if ($classMetadata->isInheritanceTypeSingleTable() && !$classMetadata->isRootEntity()) {
// if we are in an inheritance hierarchy, only apply this once
return;
}
$classMetadata->setTableName($this->prefix . $classMetadata->getTableName());
foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
if ($mapping['type'] == \Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY
// Check if "joinTable" exists, it can be null if this field is the reverse side of a ManyToMany relationship
&& array_key_exists('name', $classMetadata->associationMappings[$fieldName]['joinTable']) ) {
$mappedTableName = $classMetadata->associationMappings[$fieldName]['joinTable']['name'];
$classMetadata->associationMappings[$fieldName]['joinTable']['name'] = $this->prefix . $mappedTableName;
}
}
}
}

View File

@@ -0,0 +1,126 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* FIXME CAN BE REMOVED
*
* @ORM\Entity
* @ORM\Table(name="demo_comment")
*
* Defines the properties of the Comment entity to represent the blog comments.
* See http://symfony.com/doc/current/book/doctrine.html#creating-an-entity-class
*
* Tip: if you have an existing database, you can generate these entity class automatically.
* See http://symfony.com/doc/current/cookbook/doctrine/reverse_engineering.html
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class Comment
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity="Post", inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
*/
private $post;
/**
* @ORM\Column(type="text")
* @Assert\NotBlank(message="comment.blank")
* @Assert\Length(
* min = "5",
* minMessage = "comment.too_short",
* max = "10000",
* maxMessage = "comment.too_long"
* )
*/
private $content;
/**
* @ORM\Column(type="string")
* @Assert\Email()
*/
private $authorEmail;
/**
* @ORM\Column(type="datetime")
* @Assert\DateTime()
*/
private $publishedAt;
public function __construct()
{
$this->publishedAt = new \DateTime();
}
/**
* @Assert\IsTrue(message = "comment.is_spam")
*/
public function isLegitComment()
{
$containsInvalidCharacters = false !== strpos($this->content, '@');
return !$containsInvalidCharacters;
}
public function getId()
{
return $this->id;
}
public function getContent()
{
return $this->content;
}
public function setContent($content)
{
$this->content = $content;
}
public function getAuthorEmail()
{
return $this->authorEmail;
}
public function setAuthorEmail($authorEmail)
{
$this->authorEmail = $authorEmail;
}
public function getPublishedAt()
{
return $this->publishedAt;
}
public function setPublishedAt(\DateTime $publishedAt)
{
$this->publishedAt = $publishedAt;
}
public function getPost()
{
return $this->post;
}
public function setPost(Post $post)
{
$this->post = $post;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
/**
* FIXME CAN BE REMOVED
*
* @ORM\Entity(repositoryClass="AppBundle\Repository\PostRepository")
* @ORM\Table(name="demo_post")
*
* Defines the properties of the Post entity to represent the blog posts.
* See http://symfony.com/doc/current/book/doctrine.html#creating-an-entity-class
*
* Tip: if you have an existing database, you can generate these entity class automatically.
* See http://symfony.com/doc/current/cookbook/doctrine/reverse_engineering.html
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class Post
{
/**
* Use constants to define configuration options that rarely change instead
* of specifying them in app/config/config.yml.
* See http://symfony.com/doc/current/best_practices/configuration.html#constants-vs-configuration-options
*/
const NUM_ITEMS = 10;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string")
* @Assert\NotBlank()
*/
private $title;
/**
* @ORM\Column(type="string")
*/
private $slug;
/**
* @ORM\Column(type="string")
* @Assert\NotBlank(message="post.blank_summary")
*/
private $summary;
/**
* @ORM\Column(type="text")
* @Assert\NotBlank(message="post.blank_content")
* @Assert\Length(min = "10", minMessage = "post.too_short_content")
*/
private $content;
/**
* @ORM\Column(type="string")
* @Assert\Email()
*/
private $authorEmail;
/**
* @ORM\Column(type="datetime")
* @Assert\DateTime()
*/
private $publishedAt;
/**
* @ORM\OneToMany(
* targetEntity="Comment",
* mappedBy="post",
* orphanRemoval=true
* )
* @ORM\OrderBy({"publishedAt" = "DESC"})
*/
private $comments;
public function __construct()
{
$this->publishedAt = new \DateTime();
$this->comments = new ArrayCollection();
}
public function getId()
{
return $this->id;
}
public function getTitle()
{
return $this->title;
}
public function setTitle($title)
{
$this->title = $title;
}
public function getSlug()
{
return $this->slug;
}
public function setSlug($slug)
{
$this->slug = $slug;
}
public function getContent()
{
return $this->content;
}
public function setContent($content)
{
$this->content = $content;
}
public function getAuthorEmail()
{
return $this->authorEmail;
}
public function setAuthorEmail($authorEmail)
{
$this->authorEmail = $authorEmail;
}
/**
* Is the given User the author of this Post?
*
* @param User $user
*
* @return bool
*/
public function isAuthor(User $user)
{
return $user->getEmail() === $this->getAuthorEmail();
}
public function getPublishedAt()
{
return $this->publishedAt;
}
public function setPublishedAt(\DateTime $publishedAt)
{
$this->publishedAt = $publishedAt;
}
public function getComments()
{
return $this->comments;
}
public function addComment(Comment $comment)
{
$this->comments->add($comment);
$comment->setPost($this);
}
public function removeComment(Comment $comment)
{
$this->comments->removeElement($comment);
}
public function getSummary()
{
return $this->summary;
}
public function setSummary($summary)
{
$this->summary = $summary;
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* FIXME CAN BE REMOVED
*
* @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
* @ORM\Table(name="demo_user")
*
* Defines the properties of the User entity to represent the application users.
* See http://symfony.com/doc/current/book/doctrine.html#creating-an-entity-class
*
* Tip: if you have an existing database, you can generate these entity class automatically.
* See http://symfony.com/doc/current/cookbook/doctrine/reverse_engineering.html
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class User implements UserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", unique=true)
*/
private $username;
/**
* @ORM\Column(type="string", unique=true)
*/
private $email;
/**
* @ORM\Column(type="string")
*/
private $password;
/**
* @ORM\Column(type="json_array")
*/
private $roles = [];
public function getId()
{
return $this->id;
}
/**
* {@inheritdoc}
*/
public function getUsername()
{
return $this->username;
}
public function setUsername($username)
{
$this->username = $username;
}
public function getEmail()
{
return $this->email;
}
public function setEmail($email)
{
$this->email = $email;
}
/**
* {@inheritdoc}
*/
public function getPassword()
{
return $this->password;
}
public function setPassword($password)
{
$this->password = $password;
}
/**
* Returns the roles or permissions granted to the user for security.
*/
public function getRoles()
{
$roles = $this->roles;
// guarantees that a user always has at least one role for security
if (empty($roles)) {
$roles[] = 'ROLE_USER';
}
return array_unique($roles);
}
public function setRoles(array $roles)
{
$this->roles = $roles;
}
/**
* Returns the salt that was originally used to encode the password.
*/
public function getSalt()
{
// See "Do you need to use a Salt?" at http://symfony.com/doc/current/cookbook/security/entity_provider.html
// we're using bcrypt in security.yml to encode the password, so
// the salt value is built-in and you don't have to generate one
return;
}
/**
* Removes sensitive data from the user.
*/
public function eraseCredentials()
{
// if you had a plainPassword property, you'd nullify it here
// $this->plainPassword = null;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Event;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* The ConfigureAdminMenuEvent is used for populating the administration navigation.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ConfigureAdminMenuEvent extends Event
{
const CONFIGURE = 'app.admin_menu_configure';
private $factory;
private $menu;
/**
* @param \Knp\Menu\FactoryInterface $factory
* @param \Knp\Menu\ItemInterface $menu
*/
public function __construct(FactoryInterface $factory, ItemInterface $menu)
{
$this->factory = $factory;
$this->menu = $menu;
}
/**
* @return \Knp\Menu\FactoryInterface
*/
public function getFactory()
{
return $this->factory;
}
/**
* @return \Knp\Menu\ItemInterface
*/
public function getMenu()
{
return $this->menu;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Event;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* The ConfigureMainMenuEvent is used for populating the main navigation.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class ConfigureMainMenuEvent extends Event
{
const CONFIGURE = 'app.main_menu_configure';
private $factory;
private $menu;
/**
* @param \Knp\Menu\FactoryInterface $factory
* @param \Knp\Menu\ItemInterface $menu
*/
public function __construct(FactoryInterface $factory, ItemInterface $menu)
{
$this->factory = $factory;
$this->menu = $menu;
}
/**
* @return \Knp\Menu\FactoryInterface
*/
public function getFactory()
{
return $this->factory;
}
/**
* @return \Knp\Menu\ItemInterface
*/
public function getMenu()
{
return $this->menu;
}
}

View File

@@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* When visiting the homepage, this listener redirects the user to the most
* appropriate localized version according to the browser settings.
*
* See http://symfony.com/doc/current/components/http_kernel/introduction.html#the-kernel-request-event
*
* @author Oleg Voronkovich <oleg-voronkovich@yandex.ru>
*/
class RedirectToPreferredLocaleListener
{
/**
* @var UrlGeneratorInterface
*/
private $urlGenerator;
/**
* List of supported locales.
*
* @var string[]
*/
private $locales = [];
/**
* @var string
*/
private $defaultLocale = '';
/**
* Constructor.
*
* @param UrlGeneratorInterface $urlGenerator
* @param string $locales Supported locales separated by '|'
* @param string|null $defaultLocale
*/
public function __construct(UrlGeneratorInterface $urlGenerator, $locales, $defaultLocale = null)
{
$this->urlGenerator = $urlGenerator;
$this->locales = explode('|', trim($locales));
if (empty($this->locales)) {
throw new \UnexpectedValueException('The list of supported locales must not be empty.');
}
$this->defaultLocale = $defaultLocale ?: $this->locales[0];
if (!in_array($this->defaultLocale, $this->locales)) {
throw new \UnexpectedValueException(sprintf('The default locale ("%s") must be one of "%s".', $this->defaultLocale, $locales));
}
// Add the default locale at the first position of the array,
// because Symfony\HttpFoundation\Request::getPreferredLanguage
// returns the first element when no an appropriate language is found
array_unshift($this->locales, $this->defaultLocale);
$this->locales = array_unique($this->locales);
}
/**
* @param GetResponseEvent $event
*/
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
// Ignore sub-requests and all URLs but the homepage
if (!$event->isMasterRequest() || '/' !== $request->getPathInfo()) {
return;
}
// Ignore requests from referrers with the same HTTP host in order to prevent
// changing language for users who possibly already selected it for this application.
if (0 === stripos($request->headers->get('referer'), $request->getSchemeAndHttpHost())) {
return;
}
$preferredLanguage = $request->getPreferredLanguage($this->locales);
if ($preferredLanguage !== $this->defaultLocale) {
$response = new RedirectResponse($this->urlGenerator->generate('homepage', ['_locale' => $preferredLanguage]));
$event->setResponse($response);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Form;
use AppBundle\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* FIXME CAN BE REMOVED
*
* Defines the form used to create and manipulate blog comments. Although in this
* case the form is trivial and we could build it inside the controller, a good
* practice is to always define your forms as classes.
* See http://symfony.com/doc/current/book/forms.html#creating-form-classes
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class CommentType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
// By default, form fields include the 'required' attribute, which enables
// the client-side form validation. This means that you can't test the
// server-side validation errors from the browser. To temporarily disable
// this validation, set the 'required' attribute to 'false':
//
// $builder->add('content', null, ['required' => false]);
$builder
->add('content')
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Comment::class,
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Form;
use AppBundle\Entity\Post;
use AppBundle\Form\Type\DateTimePickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* FIXME CAN BE REMOVED
*
* Defines the form used to create and manipulate blog posts.
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class PostType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
// For the full reference of options defined by each form field type
// see http://symfony.com/doc/current/reference/forms/types.html
// By default, form fields include the 'required' attribute, which enables
// the client-side form validation. This means that you can't test the
// server-side validation errors from the browser. To temporarily disable
// this validation, set the 'required' attribute to 'false':
//
// $builder->add('title', null, ['required' => false, ...]);
$builder
->add('title', null, [
'attr' => ['autofocus' => true],
'label' => 'label.title',
])
->add('summary', TextareaType::class, [
'label' => 'label.summary',
])
->add('content', null, [
'attr' => ['rows' => 20],
'label' => 'label.content',
])
->add('authorEmail', null, [
'label' => 'label.author_email',
])
->add('publishedAt', DateTimePickerType::class, [
'label' => 'label.published_at',
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Post::class,
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Form\Type;
use AppBundle\Utils\MomentFormatConverter;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the custom form field type used to manipulate datetime values across
* Bootstrap Date\Time Picker javascript plugin.
* See http://symfony.com/doc/current/cookbook/form/create_custom_field_type.html
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class DateTimePickerType extends AbstractType
{
/**
* @var MomentFormatConverter
*/
private $formatConverter;
public function __construct()
{
$this->formatConverter = new MomentFormatConverter();
}
/**
* {@inheritdoc}
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['attr']['data-date-format'] = $this->formatConverter->convert($options['format']);
$view->vars['attr']['data-date-locale'] = \Locale::getDefault();
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'widget' => 'single_text',
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateTimeType::class;
}
}

View File

@@ -0,0 +1,129 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Menu;
use Knp\Menu\FactoryInterface;
use AppBundle\Event\ConfigureMainMenuEvent;
use AppBundle\Event\ConfigureAdminMenuEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;
/**
* Class MenuBuilder
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class MenuBuilder
{
/**
* @var FactoryInterface
*/
private $factory;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var AuthorizationChecker
*/
private $security;
/**
* MenuBuilder constructor.
* @param FactoryInterface $factory
* @param EventDispatcherInterface $dispatcher
* @param AuthorizationChecker $security
*/
public function __construct(FactoryInterface $factory,
EventDispatcherInterface $dispatcher,
AuthorizationChecker $security)
{
$this->factory = $factory;
$this->eventDispatcher = $dispatcher;
$this->security = $security;
}
/**
* Generate the main menu.
*
* @param array $options
* @return \Knp\Menu\ItemInterface
*/
public function createMainMenu(array $options)
{
$menu = $this->factory->createItem('main');
$menu->setChildrenAttribute('class', 'nav navbar-nav navbar-right');
$isLoggedIn = $this->security->isGranted('IS_AUTHENTICATED_FULLY');
$isAdmin = $isLoggedIn && $this->security->isGranted('ROLE_ADMIN');
if ($isLoggedIn) {
$item = $menu->addChild('Homepage', array('route' => 'blog_index'));
$item->setLabel('menu.homepage');
$item->setChildrenAttribute('icon', 'home');
}
$this->eventDispatcher->dispatch(
ConfigureMainMenuEvent::CONFIGURE,
new ConfigureMainMenuEvent($this->factory, $menu)
);
if ($isAdmin) {
$item = $menu->addChild('Administration', array('route' => 'admin_post_index'));
$item->setLabel('menu.admin');
$item->setChildrenAttribute('icon', 'lock');
}
if ($isLoggedIn) {
$item = $menu->addChild('Logout', array('route' => 'security_logout'));
$item->setLabel('menu.logout');
$item->setChildrenAttribute('icon', 'sign-out');
}
return $menu;
}
/**
* Generate the admin menu.
*
* @param array $options
* @return \Knp\Menu\ItemInterface
*/
public function createAdminMenu(array $options)
{
$menu = $this->factory->createItem('admin');
$menu->setChildrenAttribute('class', 'nav navbar-nav navbar-right');
$isLoggedIn = $this->security->isGranted('IS_AUTHENTICATED_FULLY');
$isAdmin = $isLoggedIn && $this->security->isGranted('ROLE_ADMIN');
// FIXME CAN BE REMOVED
if ($isAdmin) {
$item = $menu->addChild('Post list', array('route' => 'admin_post_index'));
$item->setLabel('menu.post_list');
$item->setChildrenAttribute('icon', 'list-alt');
}
$this->eventDispatcher->dispatch(
ConfigureAdminMenuEvent::CONFIGURE,
new ConfigureAdminMenuEvent($this->factory, $menu)
);
if ($isLoggedIn) {
$item = $menu->addChild('Logout', array('route' => 'security_logout'));
$item->setLabel('menu.logout');
$item->setChildrenAttribute('icon', 'sign-out');
}
return $menu;
}
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Repository;
use AppBundle\Entity\Post;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
/**
* FIXME CAN BE REMOVED
*
* This custom Doctrine repository contains some methods which are useful when
* querying for blog post information.
* See http://symfony.com/doc/current/book/doctrine.html#custom-repository-classes
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class PostRepository extends EntityRepository
{
/**
* @return Query
*/
public function queryLatest()
{
return $this->getEntityManager()
->createQuery('
SELECT p
FROM AppBundle:Post p
WHERE p.publishedAt <= :now
ORDER BY p.publishedAt DESC
')
->setParameter('now', new \DateTime())
;
}
/**
* @param int $page
*
* @return Pagerfanta
*/
public function findLatest($page = 1)
{
$paginator = new Pagerfanta(new DoctrineORMAdapter($this->queryLatest(), false));
$paginator->setMaxPerPage(Post::NUM_ITEMS);
$paginator->setCurrentPage($page);
return $paginator;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Repository;
use Doctrine\ORM\EntityRepository;
/**
* FIXME CAN BE REMOVED
*
* This custom Doctrine repository is empty because so far we don't need any custom
* method to query for application user information. But it's always a good practice
* to define a custom repository that will be used when the application grows.
* See http://symfony.com/doc/current/book/doctrine.html#custom-repository-classes
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class UserRepository extends EntityRepository
{
}

View File

@@ -0,0 +1,75 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Tests\Controller\Admin;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* FIXME CAN BE REMOVED
*
* Functional test for the controllers defined inside the BlogController used
* for managing the blog in the backend.
* See http://symfony.com/doc/current/book/testing.html#functional-tests
*
* Whenever you test resources protected by a firewall, consider using the
* technique explained in:
* http://symfony.com/doc/current/cookbook/testing/http_authentication.html
*
* Execute the application tests using this command (requires PHPUnit to be installed):
*
* $ cd your-symfony-project/
* $ phpunit -c app
*
*/
class BlogControllerTest extends WebTestCase
{
public function testRegularUsersCannotAccessToTheBackend()
{
$client = static::createClient([], [
'PHP_AUTH_USER' => 'john_user',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/');
$this->assertEquals(Response::HTTP_FORBIDDEN, $client->getResponse()->getStatusCode());
}
public function testAdministratorUsersCanAccessToTheBackend()
{
$client = static::createClient([], [
'PHP_AUTH_USER' => 'anna_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$client->request('GET', '/en/admin/post/');
$this->assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode());
}
public function testIndex()
{
$client = static::createClient([], [
'PHP_AUTH_USER' => 'anna_admin',
'PHP_AUTH_PW' => 'kitten',
]);
$crawler = $client->request('GET', '/en/admin/post/');
$this->assertCount(
30,
$crawler->filter('body#admin_post_index #main tbody tr'),
'The backend homepage displays all the available posts.'
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use AppBundle\Entity\Post;
/**
* FIXME CAN BE REMOVED
*
* Functional test for the controllers defined inside BlogController.
* See http://symfony.com/doc/current/book/testing.html#functional-tests
*
* Execute the application tests using this command (requires PHPUnit to be installed):
*
* $ cd your-symfony-project/
* $ phpunit -c app
*
*/
class BlogControllerTest extends WebTestCase
{
public function testIndex()
{
$client = static::createClient();
$crawler = $client->request('GET', '/en/blog/');
$this->assertCount(
Post::NUM_ITEMS,
$crawler->filter('article.post'),
'The homepage displays the right number of posts.'
);
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* FIXME CAN BE REMOVED
*
* Functional test that implements a "smoke test" of all the public and secure
* URLs of the application.
* See http://symfony.com/doc/current/best_practices/tests.html#functional-tests.
*
* Execute the application tests using this command (requires PHPUnit to be installed):
*
* $ cd your-symfony-project/
* $ phpunit -c app
*
*/
class DefaultControllerTest extends WebTestCase
{
/**
* PHPUnit's data providers allow to execute the same tests repeated times
* using a different set of data each time.
* See http://symfony.com/doc/current/cookbook/form/unit_testing.html#testing-against-different-sets-of-data.
*
* @dataProvider getPublicUrls
*/
public function testPublicUrls($url)
{
$client = self::createClient();
$client->request('GET', $url);
$this->assertTrue(
$client->getResponse()->isSuccessful(),
sprintf('The %s public URL loads correctly.', $url)
);
}
/**
* The application contains a lot of secure URLs which shouldn't be
* publicly accessible. This tests ensures that whenever a user tries to
* access one of those pages, a redirection to the login form is performed.
*
* @dataProvider getSecureUrls
*/
public function testSecureUrls($url)
{
$client = self::createClient();
$client->request('GET', $url);
$this->assertTrue($client->getResponse()->isRedirect());
$this->assertEquals(
'http://localhost/en/login',
$client->getResponse()->getTargetUrl(),
sprintf('The %s secure URL redirects to the login form.', $url)
);
}
public function getPublicUrls()
{
yield ['/'];
yield ['/en/blog/'];
yield ['/en/blog/posts/morbi-tempus-commodo-mattis'];
yield ['/en/login'];
}
public function getSecureUrls()
{
yield ['/en/admin/post/'];
yield ['/en/admin/post/new'];
yield ['/en/admin/post/1'];
yield ['/en/admin/post/1/edit'];
}
}

View File

@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Tests\Utils;
use AppBundle\Utils\Slugger;
/**
* FIXME CAN BE REMOVED
*
* Unit test for the application utils.
* See http://symfony.com/doc/current/book/testing.html#unit-tests
*
* Execute the application tests using this command (requires PHPUnit to be installed):
*
* $ cd your-symfony-project/
* $ phpunit -c app
*
*/
class SluggerTest extends \PHPUnit_Framework_TestCase
{
/**
* @dataProvider getSlugs
*/
public function testSlugify($string, $slug)
{
$slugger = new Slugger();
$result = $slugger->slugify($string);
$this->assertEquals($slug, $result);
}
public function getSlugs()
{
yield ['Lorem Ipsum' , 'lorem-ipsum'];
yield [' Lorem Ipsum ' , 'lorem-ipsum'];
yield [' lOrEm iPsUm ' , 'lorem-ipsum'];
yield ['!Lorem Ipsum!' , 'lorem-ipsum'];
yield ['lorem-ipsum' , 'lorem-ipsum'];
}
}

View File

@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Twig;
use AppBundle\Utils\Markdown;
use Symfony\Component\Intl\Intl;
/**
* This Twig extension adds new filters:
* - 'md2html' to transform Markdown contents into HTML contents
* - 'gmdate' to transform timestamps into a gmdate() formatted string
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class Extensions extends \Twig_Extension
{
/**
* @var Markdown
*/
private $parser;
/**
* @var array
*/
private $locales;
/**
* Extensions constructor.
* @param Markdown $parser
* @param $locales
*/
public function __construct(Markdown $parser, $locales)
{
$this->parser = $parser;
$this->locales = $locales;
}
/**
* {@inheritdoc}
*/
public function getFilters()
{
return [
new \Twig_SimpleFilter('md2html', [$this, 'markdownToHtml'], ['is_safe' => ['html']]),
new \Twig_SimpleFilter('gmdate', array($this, 'gmdate')),
];
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new \Twig_SimpleFunction('locales', [$this, 'getLocales']),
];
}
/**
* Transform a timestamp into a gmdate() formatted string.
*
* @param $seconds
* @param string $format
* @return false|string
*/
public function gmdate($seconds, $format = 'H:i')
{
return gmdate($format, $seconds);
}
/**
* Transforms the given Markdown content into HTML content.
*
* @param string $content
*
* @return string
*/
public function markdownToHtml($content)
{
return $this->parser->toHtml($content);
}
/**
* Takes the list of codes of the locales (languages) enabled in the
* application and returns an array with the name of each locale written
* in its own language (e.g. English, Français, Español, etc.)
*
* @return array
*/
public function getLocales()
{
$localeCodes = explode('|', $this->locales);
$locales = [];
foreach ($localeCodes as $localeCode) {
$locales[] = ['code' => $localeCode, 'name' => Intl::getLocaleBundle()->getLocaleName($localeCode, $localeCode)];
}
return $locales;
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'kimai.extension';
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Utils;
/**
* This class is a light interface between an external Markdown parser library
* and the application. It's generally recommended to create these light interfaces
* to decouple your application from the implementation details of the third-party library.
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class Markdown
{
/**
* @var \Parsedown
*/
private $parser;
/**
* @var \HTMLPurifier
*/
private $purifier;
public function __construct()
{
$this->parser = new \Parsedown();
$purifierConfig = \HTMLPurifier_Config::create([
'Cache.DefinitionImpl' => null, // Disable caching
]);
$this->purifier = new \HTMLPurifier($purifierConfig);
}
/**
* @param string $text
*
* @return string
*/
public function toHtml($text)
{
$html = $this->parser->text($text);
$safeHtml = $this->purifier->purify($html);
return $safeHtml;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Utils;
/**
* This class is used to convert PHP date format to moment.js format
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class MomentFormatConverter
{
/**
* This defines the mapping between PHP ICU date format (key) and moment.js date format (value)
* For ICU formats see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax
* For Moment formats see http://momentjs.com/docs/#/displaying/format/
*
* @var array
*/
private static $formatConvertRules = [
// year
'yyyy' => 'YYYY', 'yy' => 'YY', 'y' => 'YYYY',
// day
'dd' => 'DD', 'd' => 'D',
// day of week
'EE' => 'ddd', 'EEEEEE' => 'dd',
// timezone
'ZZZZZ' => 'Z', 'ZZZ' => 'ZZ',
// letter 'T'
'\'T\'' => 'T',
];
/**
* Returns associated moment.js format.
*
* @param string $format PHP Date format
*
* @return string
*/
public function convert($format)
{
return strtr($format, self::$formatConvertRules);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Utils;
/**
* FIXME CAN BE REMOVED
*
* This class is used to provide an example of integrating simple classes as
* services into a Symfony application.
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class Slugger
{
/**
* @param string $string
*
* @return string
*/
public function slugify($string)
{
return trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(strip_tags($string))), '-');
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle\Controller;
use TimesheetBundle\Entity\Timesheet;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
/**
* Controller used to manage timesheet contents in the public part of the site.
*
* @Route("/timesheet")
* @Security("has_role('ROLE_CUSTOMER')")
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetController extends Controller
{
/**
* @Route("/", defaults={"page": 1}, name="timesheet")
* @Route("/timesheet/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated")
* @Method("GET")
* @Cache(smaxage="10")
*/
public function indexAction($page)
{
$entries = $this->getDoctrine()->getRepository(Timesheet::class)->findLatest($page);
return $this->render('TimesheetBundle:timesheet:index.html.twig', ['entries' => $entries]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle\DataFixtures\ORM;
use AppBundle\Entity\User;
use TimesheetBundle\Entity\Timesheet;
use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use AppBundle\DataFixtures\ORM\LoadFixtures as AppBundleLoadFixtures;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests. Execute this command to load the data:
*
* $ php bin/console doctrine:fixtures:load
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class LoadFixtures extends AppBundleLoadFixtures
{
const AMOUNT_ACTIVITIES = 20;
const AMOUNT_TIMESHEET = 1000;
const AMOUNT_PROJECTS = 10;
const AMOUNT_CUSTOMER = 10;
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
$this->loadTimesheet($manager);
}
private function loadTimesheet(ObjectManager $manager)
{
$amountUsers = count($manager->getRepository(User::class)->findAll());
for ($i = 0; $i <= self::AMOUNT_TIMESHEET; $i++) {
$start = new \DateTime();
$start = $start->modify('- ' . (rand(1, 400)) . ' days');
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
$end = clone $start;
$end = $end->modify('+ '.(rand(1, 43200)).' seconds');
$entry = new Timesheet();
$entry->setProjectid(rand(1, self::AMOUNT_PROJECTS));
$entry->setActivityid(rand(1, self::AMOUNT_ACTIVITIES));
$entry->setStatusid(1); // TODO
$entry->setBillable(true);
$entry->setBudget(0);
$entry->setCleared(false);
$entry->setComment($this->getRandomPhrase());
$entry->setDescription($this->getRandomPhrase());
$entry->setLocation($this->getRandomLocation());
$entry->setStart($start->getTimestamp());
$entry->setEnd($end->getTimestamp());
$entry->setDuration($end->modify('- ' . $start->getTimestamp() . ' seconds')->getTimestamp());
$entry->setUserid(rand(1, $amountUsers));
//$entry->setApproved(false); // TODO
//$entry->setFixedrate(); // TODO
//$entry->setRate(); // TODO
//$entry->setTrackingnumber(); // TODO
$manager->persist($entry);
}
$manager->flush();
}
private function getLocations()
{
return [
'Köln',
'München',
'New York',
'Buenos Aires',
'Hawai',
'Amsterdam',
'London',
'San Francisco',
'Tokio',
'Berlin',
'Sao Paulo',
'Mexico City',
];
}
private function getRandomLocation()
{
$titles = $this->getLocations();
return $titles[array_rand($titles)];
}
}

View File

@@ -0,0 +1,604 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* Timesheet entity.
*
* @ORM\Entity(repositoryClass="TimesheetBundle\Repository\TimesheetRepository")
* @ORM\Table(name="timeSheet", indexes={@ORM\Index(name="userID", columns={"userID"}), @ORM\Index(name="projectID", columns={"projectID"}), @ORM\Index(name="activityID", columns={"activityID"})})
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class Timesheet
{
/**
* @var integer
*
* @ORM\Column(name="start", type="integer", nullable=false)
*/
private $start = '0';
/**
* @var integer
*
* @ORM\Column(name="end", type="integer", nullable=false)
*/
private $end = '0';
/**
* @var integer
*
* @ORM\Column(name="duration", type="integer", nullable=false)
*/
private $duration = '0';
/**
* @var integer
*
* @ORM\Column(name="userID", type="integer", nullable=false)
*/
private $userid;
/**
* @var integer
*
* @ORM\Column(name="projectID", type="integer", nullable=false)
*/
private $projectid;
/**
* @var integer
*
* @ORM\Column(name="activityID", type="integer", nullable=false)
*/
private $activityid;
/**
* @var string
*
* @ORM\Column(name="description", type="text", length=65535, nullable=true)
*/
private $description;
/**
* @var string
*
* @ORM\Column(name="comment", type="text", length=65535, nullable=true)
*/
private $comment;
/**
* @var boolean
*
* @ORM\Column(name="commentType", type="boolean", nullable=false)
*/
private $commenttype = '0';
/**
* @var boolean
*
* @ORM\Column(name="cleared", type="boolean", nullable=false)
*/
private $cleared = '0';
/**
* @var string
*
* @ORM\Column(name="location", type="string", length=50, nullable=true)
*/
private $location;
/**
* @var string
*
* @ORM\Column(name="trackingNumber", type="string", length=30, nullable=true)
*/
private $trackingnumber;
/**
* @var string
*
* @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false)
*/
private $rate = '0.00';
/**
* @var string
*
* @ORM\Column(name="fixedRate", type="decimal", precision=10, scale=2, nullable=false)
*/
private $fixedrate = '0.00';
/**
* @var string
*
* @ORM\Column(name="budget", type="decimal", precision=10, scale=2, nullable=true)
*/
private $budget;
/**
* @var string
*
* @ORM\Column(name="approved", type="decimal", precision=10, scale=2, nullable=true)
*/
private $approved;
/**
* @var integer
*
* @ORM\Column(name="statusID", type="smallint", nullable=false)
*/
private $statusid;
/**
* @var boolean
*
* @ORM\Column(name="billable", type="boolean", nullable=true)
*/
private $billable;
/**
* @var integer
*
* @ORM\Column(name="timeEntryID", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $timeentryid;
/**
* Set start
*
* @param integer $start
*
* @return KimaiTimesheet
*/
public function setStart($start)
{
$this->start = $start;
return $this;
}
/**
* Get start
*
* @return integer
*/
public function getStart()
{
return $this->start;
}
/**
* Set end
*
* @param integer $end
*
* @return KimaiTimesheet
*/
public function setEnd($end)
{
$this->end = $end;
return $this;
}
/**
* Get end
*
* @return integer
*/
public function getEnd()
{
return $this->end;
}
/**
* Set duration
*
* @param integer $duration
*
* @return KimaiTimesheet
*/
public function setDuration($duration)
{
$this->duration = $duration;
return $this;
}
/**
* Get duration
*
* @return integer
*/
public function getDuration()
{
return $this->duration;
}
/**
* Set userid
*
* @param integer $userid
*
* @return KimaiTimesheet
*/
public function setUserid($userid)
{
$this->userid = $userid;
return $this;
}
/**
* Get userid
*
* @return integer
*/
public function getUserid()
{
return $this->userid;
}
/**
* Set projectid
*
* @param integer $projectid
*
* @return KimaiTimesheet
*/
public function setProjectid($projectid)
{
$this->projectid = $projectid;
return $this;
}
/**
* Get projectid
*
* @return integer
*/
public function getProjectid()
{
return $this->projectid;
}
/**
* Set activityid
*
* @param integer $activityid
*
* @return KimaiTimesheet
*/
public function setActivityid($activityid)
{
$this->activityid = $activityid;
return $this;
}
/**
* Get activityid
*
* @return integer
*/
public function getActivityid()
{
return $this->activityid;
}
/**
* Set description
*
* @param string $description
*
* @return KimaiTimesheet
*/
public function setDescription($description)
{
$this->description = $description;
return $this;
}
/**
* Get description
*
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* Set comment
*
* @param string $comment
*
* @return KimaiTimesheet
*/
public function setComment($comment)
{
$this->comment = $comment;
return $this;
}
/**
* Get comment
*
* @return string
*/
public function getComment()
{
return $this->comment;
}
/**
* Set commenttype
*
* @param boolean $commenttype
*
* @return KimaiTimesheet
*/
public function setCommenttype($commenttype)
{
$this->commenttype = $commenttype;
return $this;
}
/**
* Get commenttype
*
* @return boolean
*/
public function getCommenttype()
{
return $this->commenttype;
}
/**
* Set cleared
*
* @param boolean $cleared
*
* @return KimaiTimesheet
*/
public function setCleared($cleared)
{
$this->cleared = $cleared;
return $this;
}
/**
* Get cleared
*
* @return boolean
*/
public function getCleared()
{
return $this->cleared;
}
/**
* Set location
*
* @param string $location
*
* @return KimaiTimesheet
*/
public function setLocation($location)
{
$this->location = $location;
return $this;
}
/**
* Get location
*
* @return string
*/
public function getLocation()
{
return $this->location;
}
/**
* Set trackingnumber
*
* @param string $trackingnumber
*
* @return KimaiTimesheet
*/
public function setTrackingnumber($trackingnumber)
{
$this->trackingnumber = $trackingnumber;
return $this;
}
/**
* Get trackingnumber
*
* @return string
*/
public function getTrackingnumber()
{
return $this->trackingnumber;
}
/**
* Set rate
*
* @param string $rate
*
* @return KimaiTimesheet
*/
public function setRate($rate)
{
$this->rate = $rate;
return $this;
}
/**
* Get rate
*
* @return string
*/
public function getRate()
{
return $this->rate;
}
/**
* Set fixedrate
*
* @param string $fixedrate
*
* @return KimaiTimesheet
*/
public function setFixedrate($fixedrate)
{
$this->fixedrate = $fixedrate;
return $this;
}
/**
* Get fixedrate
*
* @return string
*/
public function getFixedrate()
{
return $this->fixedrate;
}
/**
* Set budget
*
* @param string $budget
*
* @return KimaiTimesheet
*/
public function setBudget($budget)
{
$this->budget = $budget;
return $this;
}
/**
* Get budget
*
* @return string
*/
public function getBudget()
{
return $this->budget;
}
/**
* Set approved
*
* @param string $approved
*
* @return KimaiTimesheet
*/
public function setApproved($approved)
{
$this->approved = $approved;
return $this;
}
/**
* Get approved
*
* @return string
*/
public function getApproved()
{
return $this->approved;
}
/**
* Set statusid
*
* @param integer $statusid
*
* @return KimaiTimesheet
*/
public function setStatusid($statusid)
{
$this->statusid = $statusid;
return $this;
}
/**
* Get statusid
*
* @return integer
*/
public function getStatusid()
{
return $this->statusid;
}
/**
* Set billable
*
* @param boolean $billable
*
* @return KimaiTimesheet
*/
public function setBillable($billable)
{
$this->billable = $billable;
return $this;
}
/**
* Get billable
*
* @return boolean
*/
public function getBillable()
{
return $this->billable;
}
/**
* Get timeentryid
*
* @return integer
*/
public function getTimeentryid()
{
return $this->timeentryid;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle\EventListener;
use AppBundle\Event\ConfigureMainMenuEvent;
use AppBundle\Event\ConfigureAdminMenuEvent;
/**
* Class Menu
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class Menu
{
/**
* @param \AppBundle\Event\ConfigureMainMenuEvent $event
*/
public function onMainMenuConfigure(ConfigureMainMenuEvent $event)
{
$menu = $event->getMenu();
$item = $menu->addChild('Timesheet', array('route' => 'timesheet'));
$item->setLabel('menu.timesheet');
$item->setChildrenAttribute('icon', 'clock-o');
}
/**
* @param \AppBundle\Event\ConfigureAdminMenuEvent $event
*/
public function onAdminMenuConfigure(ConfigureAdminMenuEvent $event)
{
$menu = $event->getMenu();
$item = $menu->addChild('TimeAdmin', array('route' => 'timesheet'));
//$item->setLabel('menu.admin_timesheet');
$item->setChildrenAttribute('icon', 'clock-o');
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle\Repository;
use TimesheetBundle\Entity\Timesheet;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
/**
* Class TimesheetRepository
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetRepository extends EntityRepository
{
/**
* @return Query
*/
public function queryLatest()
{
return $this->getEntityManager()
->createQuery('
SELECT p
FROM TimesheetBundle:Timesheet p
ORDER BY p.start DESC
');
}
/**
* @param int $page
*
* @return Pagerfanta
*/
public function findLatest($page = 1)
{
$paginator = new Pagerfanta(new DoctrineORMAdapter($this->queryLatest(), false));
$paginator->setMaxPerPage(25);
$paginator->setCurrentPage($page);
return $paginator;
}
}

View File

@@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block body_id 'timesheet_index' %}
{% block main %}
{% if entries %}
<table class="table table-striped">
<thead>
<tr>
<th><i class="fa fa-calendar"></i> {{ 'label.date'|trans }}</th>
<th><i class="fa fa-circle-o"></i> {{ 'label.start'|trans }}</th>
<th><i class="fa fa-circle"></i> {{ 'label.end'|trans }}</th>
<th><i class="fa fa-clock-o"></i> {{ 'label.duration'|trans }}</th>
<th><i class="fa fa-user"></i> {{ 'label.user'|trans }}</th>
<th>{{ 'label.description'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry.start|date("m/d/Y") }}</td>
<td>{{ entry.start|date("H:i") }}</td>
<td>{{ entry.end|date("H:i") }}</td>
<td>{{ entry.duration|gmdate }}</td>
<td>{{ entry.userid }}</td>
<td>{{ entry.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="well">{{ 'post.no_posts_found'|trans }}</div>
{% endif %}
<div class="navigation text-center">
{{ pagerfanta(entries, 'twitter_bootstrap3_translated', { routeName: 'timesheet_paginated' }) }}
</div>
{% endblock %}
{% block sidebar %}
{{ parent() }}
{% endblock %}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace TimesheetBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* This class defines the Bundle for all Timesheet related topics
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetBundle extends Bundle
{
}