added global activities (#259)

This commit is contained in:
Kevin Papst
2018-11-06 23:10:34 +01:00
committed by GitHub
parent c66dfbc653
commit db7de3aac1
70 changed files with 1168 additions and 264 deletions

View File

@@ -68,7 +68,7 @@ chmod -R g+rw var/
cp .env.dist .env
```
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite.
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite, but SQLite is [not recommended](var/docs/faq.md) for production usage.
Configure the database connection string in your the `.env` file:
```
# adjust all settings in .env to your needs

165
assets/css/app.scss Normal file
View File

@@ -0,0 +1,165 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
.error-page {
margin-bottom: 50px;
}
/* ================================ CONTENT ================================ */
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
}
/* ================================ NAVBAR ================================ */
.navbar-nav > li > a.ddt-large {
padding-top: 12px;
padding-bottom: 9px;
}
li.messages-menu ul.menu li .pull-left i {
color: #dd4b39;
}
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.navbar-nav .start_record {
text-align: center;
}
/*
.ticktac:hover i.running{
color: #4ff131;
}
li.open .ticktac i.running{
color: #4ff131;
}
.ticktac i.stopped{
color: #fff;
}
.ticktac:hover i.stopped{
color: #4ff131;
}
.user-panel>.info {
left: 40px;
padding-top: 0px;
}
*/
/* ================================ TOOLBAR ================================ */
/* Page based action buttons in the upper right corner of the content area */
.content-header > .breadcrumb {
position: absolute;
float: right;
background: transparent;
}
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
.control-sidebar {
select {
color: $input-color;
background-color: $input-bg;
}
div.image {
img.img-circle {
max-width: 34px;
}
}
}
/* The filter form is available on most pages above the datatable */
@media (min-width: 768px) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
}
}
}
/* ================================ SIDEBAR ================================ */
/* Used for the Sidebar UserPanel (which is deactivated right now) */
.user-panel>.info {
left: 80px;
}
/* ================================ FOOTER ================================ */
footer.main-footer {
padding: 5px;
font-size: 80%;
}
/* ================================ PRINT ================================ */
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
/* ================================ INVOICES ================================ */
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}

View File

@@ -84,6 +84,28 @@ $(function() {
$(this).trigger("change");
});
$('select[data-related-select]').change(function() {
var apiUrl = $(this).attr('data-api-url').replace('-s-', $(this).val());
var targetSelect = $(this).attr('data-related-select');
$.ajax({
url: apiUrl,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
method: 'GET',
dataType: 'json',
success: function(data){
$('#' + targetSelect).find('option').remove().end().find('optgroup').remove().end();
$.each(data, function(i, obj) {
$('#' + targetSelect).append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
$('#' + targetSelect).trigger('change')
}
});
});
/*
$('input').iCheck({
checkboxClass: 'icheckbox_square-blue',

View File

@@ -18,14 +18,17 @@ $(document).ready(function () {
case 'customer':
if ($(this).val() === '') {
$('.toolbar form select#project').parent().remove();
//$('.toolbar form select#project').parent().parent().remove();
} else {
$('.toolbar form select#project').val('');
}
$('.toolbar form select#activity').parent().remove();
//$('.toolbar form select#activity').parent().parent().remove();
break;
case 'project':
if ($(this).val() === '') {
$('.toolbar form select#activity').parent().remove();
//$('.toolbar form select#activity').parent().parent().remove();
} else {
$('.toolbar form select#activity').val('');
}

View File

@@ -6,11 +6,11 @@ fos_rest:
# formats:
# name: ~
# unauthorized_challenge: null
# param_fetcher_listener:
# enabled: false
param_fetcher_listener:
enabled: true
# force: false
# service: null
# cache_dir: '%kernel.cache_dir%/fos_rest'
cache_dir: '%kernel.cache_dir%/fos_rest'
# allowed_methods_listener:
# enabled: false
# service: null

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"build/app.js": "/build/app.js?3c09b71f2907a08b4e32",
"build/app.js": "/build/app.js?07d671a96df83ac52da6",
"build/app.css": "/build/app.css?6501689dff217d14b179e3049295343e",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",

View File

@@ -13,7 +13,10 @@ namespace App\API;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation as API;
@@ -55,12 +58,31 @@ class ActivityController extends Controller
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* )
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, only global activities will be returned.")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities")
* @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Pass true to fetch only global activities")
*
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ActivityQuery();
$query->setOrderGlobalsFirst(true)
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS);
if (null !== ($globals = $paramFetcher->get('globals'))) {
$query->setGlobalsOnly(true);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
return $this->viewHandler->handle($view);

View File

@@ -13,7 +13,10 @@ namespace App\API;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
@@ -55,12 +58,26 @@ class ProjectController extends Controller
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref=@Model(type=Project::class)),
* )
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ProjectQuery();
$query->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS);
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
return $this->viewHandler->handle($view);

View File

@@ -63,8 +63,8 @@ class TimesheetEntity
$this->start = $entry->getBegin();
$this->title = $entry->getActivity()->getName();
$this->description = $entry->getDescription();
$this->customer = $entry->getActivity()->getProject()->getCustomer()->getName();
$this->project = $entry->getActivity()->getProject()->getName();
$this->customer = $entry->getProject()->getCustomer()->getName();
$this->project = $entry->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (null === $entry->getEnd()) {

View File

@@ -37,12 +37,11 @@ class ActivityController extends AbstractController
public function recentActivitiesAction()
{
$user = $this->getUser();
// TODO make days configurable
$activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
$entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
return $this->render(
'navbar/recent-activities.html.twig',
['activities' => $activeEntries]
['entries' => $entries]
);
}
}

View File

@@ -9,7 +9,6 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
@@ -19,6 +18,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Controller used to manage timesheets.
@@ -143,20 +143,36 @@ class TimesheetController extends AbstractController
}
/**
* The route to start a running entry.
* The route to re-start a timesheet entry.
*
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @Security("is_granted('start', activity)")
* @Security("is_granted('start', timesheet)")
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function startAction(Activity $activity)
public function startAction(ValidatorInterface $validator, Timesheet $timesheet)
{
$user = $this->getUser();
try {
$this->getRepository()->startRecording($user, $activity);
$entry = new Timesheet();
$entry
->setBegin(new \DateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
$errors = $validator->validate($entry);
if (count($errors) > 0) {
$this->flashError('timesheet.start.error', ['%reason%' => $errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()]);
} else {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}

View File

@@ -30,6 +30,8 @@ class CustomerFixtures extends Fixture
public const MAX_CUSTOMERS = 15;
public const MIN_BUDGET = 0;
public const MAX_BUDGET = 100000;
public const MIN_GLOBAL_ACTIVITIES = 5;
public const MAX_GLOBAL_ACTIVITIES = 50;
public const MIN_PROJECTS_PER_CUSTOMER = 10;
public const MAX_PROJECTS_PER_CUSTOMER = 50;
public const MIN_ACTIVITIES_PER_PROJECT = 0;
@@ -66,6 +68,16 @@ class CustomerFixtures extends Fixture
$manager->flush();
$manager->clear();
}
$amountGlobalActivities = rand(self::MIN_GLOBAL_ACTIVITIES, self::MAX_GLOBAL_ACTIVITIES);
for ($c = 1; $c <= $amountGlobalActivities; $c++) {
$visibleActivity = 0 != $a % 3;
$activity = $this->createActivity($faker, null, $visibleActivity);
$manager->persist($activity);
}
$manager->flush();
$manager->clear();
}
/**
@@ -113,11 +125,11 @@ class CustomerFixtures extends Fixture
/**
* @param Generator $faker
* @param Project $project
* @param Project|null $project
* @param bool $visible
* @return Activity
*/
private function createActivity(Generator $faker, Project $project, $visible)
private function createActivity(Generator $faker, ?Project $project, $visible)
{
$entry = new Activity();
$entry

View File

@@ -10,6 +10,7 @@
namespace App\DataFixtures;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
@@ -56,6 +57,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
{
$allUser = $this->getAllUsers($manager);
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);
$faker = Factory::create();
@@ -80,6 +82,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
$description
);
@@ -98,6 +101,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
null,
false
);
@@ -126,6 +130,22 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
*/
protected function getAllProjects(ObjectManager $manager)
{
$all = [];
/* @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param ObjectManager $manager
* @return Activity[]
@@ -133,7 +153,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
protected function getAllActivities(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
/* @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -142,7 +162,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
private function createTimesheetEntry(User $user, Activity $activity, $description, $setEndDate = true)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, $setEndDate = true)
{
$start = new \DateTime();
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
@@ -151,6 +171,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = new Timesheet();
$entry
->setActivity($activity)
->setProject($activity->getProject() ?? $project)
->setDescription($description)
->setUser($user)
->setBegin($start);

View File

@@ -34,7 +34,6 @@ class Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="activities")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $project;

View File

@@ -33,7 +33,7 @@ class Project
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="projects")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
@@ -101,6 +101,13 @@ class Project
*/
private $hourlyRate = null;
/**
* @var Timesheet[]
*
* @ORM\OneToMany(targetEntity="App\Entity\Timesheet", mappedBy="project")
*/
private $timesheets;
/**
* @return int
*/
@@ -118,8 +125,8 @@ class Project
}
/**
* @param $customer
* @return $this
* @param Customer $customer
* @return Project
*/
public function setCustomer($customer)
{
@@ -129,8 +136,6 @@ class Project
}
/**
* Set name
*
* @param string $name
* @return Project
*/
@@ -206,6 +211,25 @@ class Project
return $this->budget;
}
/**
* @param Timesheet[] $timesheets
* @return Project
*/
public function setTimesheets($timesheets)
{
$this->timesheets = $timesheets;
return $this;
}
/**
* @return Timesheet[]
*/
public function getTimesheets()
{
return $this->timesheets;
}
/**
* @param Activity[] $activities
* @return Project

View File

@@ -64,7 +64,7 @@ class Timesheet
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $user;
@@ -73,11 +73,20 @@ class Timesheet
* @var Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Activity", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $activity;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $project;
/**
* @var string
*
@@ -232,6 +241,25 @@ class Timesheet
return $this->activity;
}
/**
* @return Project
*/
public function getProject()
{
return $this->project;
}
/**
* @param Project $project
* @return Timesheet
*/
public function setProject(Project $project)
{
$this->project = $project;
return $this;
}
/**
* Set description
*
@@ -317,6 +345,8 @@ class Timesheet
}
/**
* These validations are used in places, where we don't use a form yet (like the API).
*
* @param ExecutionContextInterface $context
* @param mixed $payload
*
@@ -324,6 +354,27 @@ class Timesheet
*/
public function validate(ExecutionContextInterface $context, $payload)
{
if (null === $this->getActivity()) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if (null === $this->getProject()) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if (null !== $this->getActivity() && null !== $this->getProject() && null !== $this->getActivity()->getProject() && $this->getActivity()->getProject() !== $this->getProject()) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if (null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')

View File

@@ -10,8 +10,10 @@
namespace App\Form;
use App\Entity\Activity;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -35,8 +37,11 @@ class ActivityEditForm extends AbstractType
$entry = $options['data'];
$project = null;
if ($entry->getId() !== null) {
$customer = null;
if (null !== $entry->getProject()) {
$project = $entry->getProject();
$customer = $project->getCustomer();
}
$builder
@@ -49,9 +54,21 @@ class ActivityEditForm extends AbstractType
'label' => 'label.comment',
'required' => false,
])
// entity type: project
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
},

View File

@@ -10,15 +10,21 @@
namespace App\Form;
use App\Entity\Timesheet;
use App\Form\Type\ActivityGroupedWithCustomerNameType;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\DurationType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -34,9 +40,12 @@ class TimesheetEditForm extends AbstractType
/** @var Timesheet $entry */
$entry = $options['data'];
$activity = null;
if ($entry->getId() !== null) {
$activity = $entry->getActivity();
$project = $entry->getProject();
$customer = null === $entry->getProject() ? null : $entry->getProject()->getCustomer();
if (null === $project && null !== $activity) {
$project = $activity->getProject();
}
if (null === $entry->getEnd() || !$options['duration_only']) {
@@ -65,7 +74,32 @@ class TimesheetEditForm extends AbstractType
}
$builder
->add('activity', ActivityGroupedWithCustomerNameType::class, [
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'data' => $customer ? $customer : '',
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
])
->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
return $repo->builderForEntityType($activity);
@@ -84,6 +118,38 @@ class TimesheetEditForm extends AbstractType
'required' => false,
])
;
/*
$builder->get('customer')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$customer = $event->getForm()->getData();
$event->getForm()->getParent()->add('project', ProjectType::class, [
'required' => true,
'placeholder' => '',
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($customer) {
return $repo->builderForEntityType(null, $customer);
},
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_activity',
'data-api-url' => ['get_activities', ['project' => '-s-']],
],
]);
}
);
*/
$builder->get('project')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) {
$project = $event->getForm()->getData();
$event->getForm()->getParent()->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($project) {
return $repo->builderForEntityType(null, $project);
},
]);
}
);
if ($options['include_user']) {
$builder->add('user', UserType::class);

View File

@@ -150,7 +150,7 @@ abstract class AbstractToolbarForm extends AbstractType
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('p.customer = :customer')->setParameter('customer', $data['customer']);
$qb->andWhere('p.customer = :customer')->setParameter('customer', $data['customer']);
return $qb;
},
@@ -176,8 +176,7 @@ abstract class AbstractToolbarForm extends AbstractType
'group_by' => null,
'required' => false,
'query_builder' => function (ActivityRepository $repo) use ($data) {
$qb = $repo->builderForEntityType();
$qb->where('a.project = :project')->setParameter('project', $data['project']);
$qb = $repo->builderForEntityType(null, $data['project']);
return $qb;
},

View File

@@ -45,6 +45,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -1,38 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Entity\Activity;
/**
* Custom form field type to select an activity which are grouped by their Projects, preceeded by their customer names.
*/
class ActivityGroupedWithCustomerNameType extends ActivityType
{
/**
* @param Activity $activity
* @param $key
* @param $index
* @return string
*/
public function groupBy(Activity $activity, $key, $index)
{
return $activity->getProject()->getCustomer()->getName();
}
/**
* @param Activity $activity
* @return string
*/
public function choiceLabel(Activity $activity)
{
return $activity->getProject()->getName() . ': ' . $activity->getName();
}
}

View File

@@ -25,7 +25,11 @@ class ActivityType extends AbstractType
*/
public function groupBy(Activity $activity, $key, $index)
{
return '[' . $activity->getProject()->getId() . '] ' . $activity->getProject()->getName();
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getName();
}
/**
@@ -47,8 +51,17 @@ class ActivityType extends AbstractType
'choice_label' => [$this, 'choiceLabel'],
'group_by' => [$this, 'groupBy'],
'query_builder' => function (ActivityRepository $repo) {
return $repo->builderForEntityType(null);
return $repo->builderForEntityType();
},
'choice_attr' => function (Activity $activity, $key, $value) {
$attributes = [];
if (null !== $activity->getProject()) {
$attributes['data-project'] = $activity->getProject()->getId();
}
return $attributes;
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -32,6 +32,7 @@ class CustomerType extends AbstractType
'query_builder' => function (CustomerRepository $repo) {
return $repo->builderForEntityType(null);
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -26,6 +26,7 @@ class PageSizeType extends AbstractType
$resolver->setDefaults([
'label' => 'label.pageSize',
'choices' => [10 => 10, 25 => 25, 50 => 50, 75 => 75, 100 => 100],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -35,6 +35,7 @@ class ProjectType extends AbstractType
'query_builder' => function (ProjectRepository $repo) {
return $repo->builderForEntityType(null);
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -31,6 +31,7 @@ class VisibilityType extends AbstractType
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Migration that adds the project column to timesheets table, to allow global activities.
* It also fixes foreign-key columns on timesheet and projects table, which are not allowed.
*/
final class Version20181031220003 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
$projects = $this->getTableName('projects');
$activities = $this->getTableName('activities');
$users = $this->getTableName('users');
$customers = $this->getTableName('customers');
if ($platform === 'sqlite') {
// project table
$this->addSql('DROP INDEX IDX_407F12069395C3F3');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $projects . ' AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects);
$this->addSql('DROP TABLE ' . $projects);
$this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL COLLATE BINARY, order_number CLOB DEFAULT NULL COLLATE BINARY, comment CLOB DEFAULT NULL COLLATE BINARY, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_407F12069395C3F3 FOREIGN KEY (customer_id) REFERENCES ' . $customers . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__' . $projects);
$this->addSql('DROP TABLE __temp__' . $projects);
$this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)');
// timesheet table
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER DEFAULT NULL, activity_id INTEGER DEFAULT NULL, project_id INTEGER DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT NOT NULL');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD project_id INT DEFAULT NULL AFTER activity_id');
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
}
// update timesheet table and insert project_id from activity table
$this->addSql('UPDATE ' . $timesheet . ' SET project_id = (SELECT project_id FROM ' . $activities . ' WHERE id = activity_id)');
// now update the timesheet table and disallow null values for all required columns (that was a bug before)
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' CHANGE project_id project_id INT NOT NULL, CHANGE user user INT NOT NULL, CHANGE activity_id activity_id INT NOT NULL');
}
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
$projects = $this->getTableName('projects');
if ($platform === 'sqlite') {
// project table
$this->addSql('DROP INDEX IDX_407F12069395C3F3');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $projects . ' AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects);
$this->addSql('DROP TABLE ' . $projects);
$this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, order_number CLOB DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, customer_id INTEGER DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__' . $projects);
$this->addSql('DROP TABLE __temp__' . $projects);
$this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)');
// timesheet table
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER DEFAULT NULL, activity_id INTEGER DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
// project table
$this->addSql('ALTER TABLE ' . $projects . ' CHANGE customer_id customer_id INT DEFAULT NULL');
// timesheet table
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP FOREIGN KEY FK_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet);
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP project_id, CHANGE user user INT DEFAULT NULL, CHANGE activity_id activity_id INT DEFAULT NULL');
}
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\ActivityStatistic;
@@ -35,7 +36,7 @@ class ActivityRepository extends AbstractRepository
/**
* @param User|null $user
* @param \DateTime|null $startFrom
* @return mixed
* @return Timesheet[]
*/
public function getRecentActivities(User $user = null, \DateTime $startFrom = null)
{
@@ -45,7 +46,7 @@ class ActivityRepository extends AbstractRepository
->distinct()
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('a.project', 'p')
->join('t.project', 'p')
->join('p.customer', 'c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere('a.visible = 1')
@@ -66,15 +67,7 @@ class ActivityRepository extends AbstractRepository
->setParameter('begin', $startFrom);
}
$results = $qb->getQuery()->getResult();
$activities = [];
/* @var Timesheet $entry */
foreach ($results as $entry) {
$activities[] = $entry->getActivity();
}
return $activities;
return $qb->getQuery()->getResult();
}
/**
@@ -118,14 +111,21 @@ class ActivityRepository extends AbstractRepository
/**
* Returns a query builder that is used for ActivityType and your own 'query_builder' option.
*
* @param Activity|null $entity
* @param Activity|string|null $activity
* @param Project|string|null $project
* @return \Doctrine\ORM\QueryBuilder
*/
public function builderForEntityType(Activity $entity = null)
public function builderForEntityType($activity = null, $project = null)
{
$query = new ActivityQuery();
$query->setHiddenEntity($entity);
$query->setHiddenEntity($activity);
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
$query->setProject($project);
$query->setOrderGlobalsFirst(true);
if (null === $activity && $project === null) {
$query->setGlobalsOnly(true);
}
return $this->findByQuery($query);
}
@@ -140,34 +140,68 @@ class ActivityRepository extends AbstractRepository
$qb->select('a', 'p', 'c')
->from(Activity::class, 'a')
->join('a.project', 'p')
->join('p.customer', 'c')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->orderBy('a.' . $query->getOrderBy(), $query->getOrder());
if (ActivityQuery::SHOW_VISIBLE == $query->getVisibility()) {
if (!$query->isExclusiveVisibility()) {
$qb->andWhere('c.visible = 1');
$qb->andWhere('p.visible = 1');
}
$qb->andWhere('a.visible = 1');
$where = $qb->expr()->andX();
if (ActivityQuery::SHOW_VISIBLE == $query->getVisibility()) {
$where->add('a.visible = :visible');
if (!$query->isExclusiveVisibility()) {
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('c.visible', ':visible'),
$qb->expr()->isNull('c.visible')
)
);
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('p.visible', ':visible'),
$qb->expr()->isNull('p.visible')
)
);
}
$qb->setParameter('visible', 1);
} elseif (ActivityQuery::SHOW_HIDDEN == $query->getVisibility()) {
$where->add('a.visible = :visible');
$qb->setParameter('visible', 0);
}
if ($query->isGlobalsOnly()) {
$where->add($qb->expr()->isNull('a.project'));
} elseif (null !== $query->getProject()) {
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('a.project', ':project'),
$qb->expr()->isNull('a.project')
)
);
$qb->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$where->add('p.customer = :customer');
$qb->setParameter('customer', $query->getCustomer());
}
$or = $qb->expr()->orX();
// this must always be the last part before the or
$or->add($where);
// this must always be the last part of the query
/** @var Activity $entity */
$entity = $query->getHiddenEntity();
if (null !== $entity) {
$qb->orWhere('a.id = :activity')->setParameter('activity', $entity);
}
} elseif (ActivityQuery::SHOW_HIDDEN == $query->getVisibility()) {
$qb->andWhere('a.visible = 0');
$or->add($qb->expr()->eq('a.id', ':activity'));
$qb->setParameter('activity', $entity);
}
if (null !== $query->getProject()) {
$qb->andWhere('a.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->andWhere('p.customer = :customer')
->setParameter('customer', $query->getCustomer());
if ($query->isOrderGlobalsFirst()) {
$qb->orderBy('a.project', 'ASC');
}
$qb->andWhere($or);
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -60,7 +60,7 @@ class CustomerRepository extends AbstractRepository
->join(Project::class, 'p')
->join(Customer::class, 'c')
->andWhere('t.activity = a.id')
->andWhere('a.project = p.id')
->andWhere('t.project = p.id')
->andWhere('p.customer = c.id')
->andWhere('c.id = :customer')
;

View File

@@ -10,6 +10,7 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Model\ProjectStatistic;
@@ -41,7 +42,7 @@ class ProjectRepository extends AbstractRepository
}
/**
* Retrieves statistics for one activity.
* Retrieves statistics for one project.
*
* @param Project $project
* @return ProjectStatistic
@@ -55,7 +56,7 @@ class ProjectRepository extends AbstractRepository
->addSelect('COUNT(DISTINCT(a.id)) as activityAmount')
->from(Activity::class, 'a')
->join(Timesheet::class, 't')
->where('a.project = :project')
->where('t.project = :project')
->andWhere('t.activity = a.id')
;
@@ -79,12 +80,14 @@ class ProjectRepository extends AbstractRepository
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param Project|null $entity
* @return \Doctrine\ORM\QueryBuilder
* @param Customer|null $customer
* @return array|QueryBuilder|Pagerfanta
*/
public function builderForEntityType(Project $entity = null)
public function builderForEntityType(Project $entity = null, Customer $customer = null)
{
$query = new ProjectQuery();
$query->setHiddenEntity($entity);
$query->setCustomer($customer);
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
return $this->findByQuery($query);

View File

@@ -17,12 +17,58 @@ use App\Entity\Project;
class ActivityQuery extends ProjectQuery
{
/**
* @var Project
* @var Project|int
*/
protected $project;
/**
* @var bool
*/
protected $orderGlobalsFirst = false;
/**
* @var bool
*/
protected $globalsOnly = false;
/**
* @return Project
* @return bool
*/
public function isOrderGlobalsFirst(): bool
{
return $this->orderGlobalsFirst;
}
/**
* @param bool $orderGlobalsFirst
* @return ActivityQuery
*/
public function setOrderGlobalsFirst(bool $orderGlobalsFirst)
{
$this->orderGlobalsFirst = $orderGlobalsFirst;
return $this;
}
/**
* @return bool
*/
public function isGlobalsOnly(): bool
{
return $this->globalsOnly;
}
/**
* @param bool $globalsOnly
* @return ActivityQuery
*/
public function setGlobalsOnly(bool $globalsOnly)
{
$this->globalsOnly = $globalsOnly;
return $this;
}
/**
* @return Project|int
*/
public function getProject()
{
@@ -30,10 +76,10 @@ class ActivityQuery extends ProjectQuery
}
/**
* @param Project $project
* @param Project|int $project
* @return $this
*/
public function setProject(Project $project = null)
public function setProject($project = null)
{
$this->project = $project;

View File

@@ -140,7 +140,7 @@ class BaseQuery
}
/**
* @param string $resultType
* @param $resultType
* @return $this
* @throws \InvalidArgumentException
*/
@@ -166,7 +166,7 @@ class BaseQuery
}
/**
* @param object $hiddenEntity
* @param object|string $hiddenEntity
* @return BaseQuery
*/
public function setHiddenEntity($hiddenEntity)

View File

@@ -17,12 +17,12 @@ use App\Entity\Customer;
class ProjectQuery extends VisibilityQuery
{
/**
* @var Customer
* @var Customer|int
*/
protected $customer;
/**
* @return Customer
* @return Customer|int
*/
public function getCustomer()
{
@@ -30,10 +30,10 @@ class ProjectQuery extends VisibilityQuery
}
/**
* @param Customer $customer
* @param Customer|int $customer
* @return $this
*/
public function setCustomer(Customer $customer = null)
public function setCustomer($customer = null)
{
$this->customer = $customer;

View File

@@ -9,7 +9,6 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Month;
@@ -54,28 +53,6 @@ class TimesheetRepository extends AbstractRepository
return true;
}
/**
* @param User $user
* @param Activity $activity
* @return Timesheet
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function startRecording(User $user, Activity $activity)
{
$entry = new Timesheet();
$entry
->setBegin(new DateTime())
->setUser($user)
->setActivity($activity);
$entityManager = $this->getEntityManager();
$entityManager->persist($entry);
$entityManager->flush();
return $entry;
}
/**
* @param string $type
* @param DateTime|null $begin
@@ -223,17 +200,17 @@ class TimesheetRepository extends AbstractRepository
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month');
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$years = [];
foreach ($qb->getQuery()->execute() as $statRow) {
$curYear = $statRow['year'];
@@ -267,7 +244,7 @@ class TimesheetRepository extends AbstractRepository
$qb->select('t', 'a', 'p', 'c')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('a.project', 'p')
->join('t.project', 'p')
->join('p.customer', 'c')
->where($qb->expr()->gt('t.begin', '0'))
->andWhere($qb->expr()->isNull('t.end'))
@@ -293,10 +270,10 @@ class TimesheetRepository extends AbstractRepository
$qb->select('t', 'a', 'p', 'c', 'u')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('t.user', 'u')
->join('a.project', 'p')
->join('p.customer', 'c')
->leftJoin('t.activity', 'a')
->leftJoin('t.user', 'u')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
->orderBy('t.' . $query->getOrderBy(), $query->getOrder());
if (null !== $query->getUser()) {
@@ -323,7 +300,7 @@ class TimesheetRepository extends AbstractRepository
$qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity());
} elseif (null !== $query->getProject()) {
$qb->andWhere('a.project = :project')
$qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$qb->andWhere('p.customer = :customer')

View File

@@ -74,7 +74,7 @@ class RateCalculator implements CalculatorInterface
return $activity->getHourlyRate();
}
$project = $activity->getProject();
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getHourlyRate()) {
return $project->getHourlyRate();
@@ -104,7 +104,7 @@ class RateCalculator implements CalculatorInterface
return $activity->getFixedRate();
}
$project = $activity->getProject();
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getFixedRate()) {
return $project->getFixedRate();

View File

@@ -44,10 +44,6 @@ class TimesheetVoter extends AbstractVoter
return false;
}
if ($subject instanceof Activity && self::START == $attribute) {
return true;
}
if (!$subject instanceof Timesheet) {
return false;
}
@@ -71,38 +67,18 @@ class TimesheetVoter extends AbstractVoter
switch ($attribute) {
case self::STOP:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canStop($subject, $user, $token);
case self::START:
if (!$subject instanceof Activity) {
return false;
}
return $this->canStart($subject, $user, $token);
case self::VIEW:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canView($subject, $user, $token);
case self::EDIT:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canEdit($subject, $user, $token);
case self::DELETE:
if (!$subject instanceof Timesheet) {
return false;
}
return $this->canDelete($subject, $user, $token);
}
@@ -122,23 +98,21 @@ class TimesheetVoter extends AbstractVoter
}
/**
* @param Activity $activity
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canStart(Activity $activity, User $user, TokenInterface $token)
protected function canStart(Timesheet $timesheet, User $user, TokenInterface $token)
{
// we could check the amount of active entries
// if a teamlead starts an entry for another user, check that this user is part of his team
if (!$activity->getVisible()) {
if (!$timesheet->getActivity()->getVisible() || !$timesheet->getProject()->getVisible()) {
return false;
}
if (!$activity->getProject()->getVisible()) {
return false;
}
if (!$activity->getProject()->getCustomer()->getVisible()) {
if (!$timesheet->getProject()->getCustomer()->getVisible()) {
return false;
}

View File

@@ -33,10 +33,18 @@
<tr>
<td>{{ entry.name }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{% if entry.project and entry.project.customer %}
<a href="{{ path('admin_customer_edit', {'id' : entry.project.customer.id}) }}">{{ widgets.label_customer(entry.project.customer) }}</a>
{% else %}
{#{ widgets.label('x', 'success') }#}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{% if entry.project %}
<a href="{{ path('admin_project_edit', {'id' : entry.project.id}) }}">{{ widgets.label_project(entry.project) }}</a>
{% else %}
{#{ widgets.label('x', 'success') }#}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>

View File

@@ -9,12 +9,18 @@
{% set params = {
'%activity%': '<strong>' ~ activity.name ~ '</strong>',
'%project%': '<strong>' ~ activity.project.name ~ '</strong>',
'%customer%': '<strong>' ~ activity.project.customer.name ~ '</strong>',
'%project%': '<strong>-</strong>',
'%customer%': '<strong>-</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>'
} %}
{% if activity.project is not null %}
{% set params = params|merge({
'%project%': '<strong>' ~ activity.project.name ~ '</strong>',
'%customer%': '<strong>' ~ activity.project.customer.name ~ '</strong>',
}) %}
{% endif %}
{{ include('default/_form_delete.html.twig', {
'message': "admin_activity.delete_confirm"|trans(params)|raw,
'form': form,

View File

@@ -48,7 +48,7 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.activity.project.customer.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% else %}
{% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ swagger_data.spec.info.title }}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700">
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui.css') }}">
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/style.css') }}">
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
<script id="swagger-data" type="application/json">{{ swagger_data|json_encode(65)|raw }}</script>
</head>
<body style="margin-top:0;">
<div id="swagger-ui" class="api-platform"></div>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-bundle.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/init-swagger-ui.js') }}"></script>
</body>
</html>

View File

@@ -6,3 +6,10 @@
{% endif %}
{{ parent() }}
{% endblock form_label %}
{% block choice_widget_collapsed %}
{% if 'data-api-url' in attr|keys %}
{% set attr = attr|merge({'data-api-url': path(attr['data-api-url']|first, attr['data-api-url']|last)}) %}
{% endif %}
{{ parent() }}
{% endblock %}

View File

@@ -33,7 +33,7 @@
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.activity.project.name }}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td class="text-center" contenteditable="true">

View File

@@ -66,7 +66,7 @@
{% for entry in model.calculator.entries %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td>{{ entry.activity.name }} / {{ entry.project.name }}</td>
<td contenteditable="true">{{ app.user.getPreferenceValue('hourly_rate')|money(model.calculator.currency) }}</td>
<td>{{ entry.duration|duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>

View File

@@ -72,7 +72,7 @@
{% if model.query.user is empty %}
<td>{{ widgets.username(entry.user) }}</td>
{% endif %}
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td>{{ entry.activity.name }} / {{ entry.project.name }}</td>
<td>{{ entry.duration|duration }}</td>
</tr>
{% endfor %}

View File

@@ -56,11 +56,18 @@
{% macro label_activity(activity) %}
{% import _self as macro %}
{% if activity.visible and activity.project.visible and activity.project.customer.visible %}
{{ macro.label(activity.name, 'primary', activity.project.customer.name ~ ': ' ~ activity.project.name) }}
{% else %}
{{ macro.label(activity.name, 'warning', activity.project.customer.name ~ ': ' ~ activity.project.name) }}
{% set isVisible = activity.visible %}
{% if isVisible and not activity.project is null %}
{% set isVisible = activity.project.visible %}
{% if isVisible and not activity.project.customer is null %}
{% set isVisible = activity.project.customer.visible %}
{% endif %}
{% endif %}
{% set label = '' %}
{% if not activity.project is null %}
{% set label = activity.project.customer.name ~ ': ' ~ activity.project.name %}
{% endif %}
{{ macro.label(activity.name, (isVisible ? 'primary' : 'warning'), label) }}
{% endmacro %}
{% macro label_project(project) %}

View File

@@ -21,7 +21,7 @@
{{ entry.activity.name }}
<small><i class="{{ 'timesheet'|icon }}"></i> {{ entry|duration }}</small>
</h4>
<p>{{ entry.activity.project.name }} ({{ entry.activity.project.customer.name }})</p>
<p>{{ entry.project.name }} ({{ entry.project.customer.name }})</p>
</a>
</li>
{% endfor %}

View File

@@ -1,17 +1,17 @@
{% if activities is not empty %}
{% if entries is defined and entries is not empty %}
<li class="dropdown notifications-menu">
<a href="#" class="dropdown-toggle ddt-large" data-toggle="dropdown">
<i class="{{ 'activity'|icon }} fa-2x"></i>
<span class="label label-success">{{ activities|length }}</span>
<span class="label label-success">{{ entries|length }}</span>
</a>
<ul class="dropdown-menu">
<li class="header">{{ 'recent.activities'|transchoice(activities|length) }}</li>
<li class="header">{{ 'recent.activities'|transchoice(entries|length) }}</li>
<li>
<ul class="menu">
{% for activity in activities %}
{% for timesheet in entries %}
<li>
<a href="{{ path('timesheet_start', {'id' : activity.id}) }}">
<i class="{{ 'start-small'|icon }} text-green"></i> {{ 'recent.activities.format'|trans({'%activity%': activity.name, '%project%': activity.project.name, '%customer%': activity.project.customer.name}) }}
<a href="{{ path('timesheet_start', {'id' : timesheet.id}) }}">
<i class="{{ 'start-small'|icon }} text-green"></i> {{ 'recent.activities.format'|trans({'%activity%': timesheet.activity.name, '%project%': timesheet.project.name, '%customer%': timesheet.project.customer.name}) }}
</a>
</li>
{% endfor %}

View File

@@ -46,8 +46,8 @@
{% endif %}
<span class="small">
{{ 'label.activity'|trans }}: {{ entry.activity.name }} |
{{ 'label.project'|trans }}: {{ entry.activity.project.name }} |
{{ 'label.customer'|trans }}: {{ entry.activity.project.customer.name }}
{{ 'label.project'|trans }}: {{ entry.project.name }} |
{{ 'label.customer'|trans }}: {{ entry.project.customer.name }}
</span>
</td>
<td>{{ entry.duration|duration }}</td>

View File

@@ -48,7 +48,7 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">{{ entry.end|date("H:i") }}</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ entry.duration|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.activity.project.customer.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate') }}">{{ entry.rate|money(entry.project.customer.currency) }}</td>
{% else %}
{% if not duration_only %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'endtime') }}">&dash;</td>

View File

@@ -138,11 +138,12 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
* @param Client $client
* @param string $url
* @param string $method
* @param array $parameters
* @return Crawler
*/
protected function request(Client $client, string $url, $method = 'GET')
protected function request(Client $client, string $url, $method = 'GET', array $parameters = [])
{
return $client->request($method, $this->createUrl($url), [], [], ['HTTP_CONTENT_TYPE' => 'application/json']);
return $client->request($method, $this->createUrl($url), $parameters, [], ['HTTP_CONTENT_TYPE' => 'application/json']);
}
/**

View File

@@ -9,7 +9,11 @@
namespace App\Tests\API;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\Query\VisibilityQuery;
use Symfony\Bundle\FrameworkBundle\Client;
/**
* @coversDefaultClass \App\API\ActivityController
@@ -22,16 +26,71 @@ class ActivityControllerTest extends APIControllerBaseTest
$this->assertUrlIsSecured('/api/activities');
}
public function testGetCollection()
protected function loadActivityTestData(Client $client)
{
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$project = $em->getRepository(Project::class)->find(1);
$project2 = new Project();
$project2->setName('Activity Test');
$em->persist($project2);
$activity = (new Activity())->setName('first one')->setComment('1')->setProject($project2);
$em->persist($activity);
$activity = (new Activity())->setName('second one')->setComment('2');
$em->persist($activity);
$activity = (new Activity())->setName('third one')->setComment('3')->setProject($project);
$em->persist($activity);
$activity = (new Activity())->setName('fourth one')->setComment('4')->setProject($project2)->setVisible(false);
$em->persist($activity);
$activity = (new Activity())->setName('fifth one')->setComment('5')->setProject($project2);
$em->persist($activity);
$activity = (new Activity())->setName('sixth one')->setComment('6')->setVisible(false);
$em->persist($activity);
$em->flush();
}
/**
* @dataProvider getCollectionTestData
*/
public function testGetCollection($url, $parameters, $expected)
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/activities');
$this->loadActivityTestData($client);
$this->assertAccessIsGranted($client, $url, 'GET', $parameters);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertNotEmpty($result);
$this->assertEquals(1, count($result));
$this->assertStructure($result[0]);
$this->assertEquals(count($expected), count($result));
for ($i = 0; $i < count($result); $i++) {
$activity = $result[$i];
$hasProject = $expected[$i][0];
$this->assertStructure($activity, $hasProject);
if ($hasProject) {
$this->assertEquals($expected[$i][0], $activity['project_id']);
}
}
}
public function getCollectionTestData()
{
yield ['/api/activities', [], [[false], [false], [true, 2], [true, 1], [true, 2]]];
yield ['/api/activities', ['globals' => 'true'], [[false], [false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_BOTH], [[false], [false], [false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[false], [false]]];
yield ['/api/activities', ['project' => '1'], [[false], [false], [true, 1]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[false], [false], [true, 2], [true, 2]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_BOTH], [[false], [false], [false], [true, 2], [true, 2], [true, 2]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false], [true, 2]]];
}
public function testGetEntity()
@@ -41,7 +100,7 @@ class ActivityControllerTest extends APIControllerBaseTest
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertStructure($result);
$this->assertStructure($result, false);
}
public function testNotFound()
@@ -49,15 +108,19 @@ class ActivityControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2');
}
protected function assertStructure(array $result)
protected function assertStructure(array $result, $project = true)
{
$expectedKeys = [
'id', 'name', 'comment', 'visible', 'project_id'
'id', 'name', 'comment', 'visible'
];
if ($project) {
$expectedKeys[] = 'project_id';
}
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'Activity entity has different amount of keys');
$this->assertEquals(count($expectedKeys), count($actual), 'Activity entity has different amount of keys: ' . $result['id']);
$this->assertEquals($expectedKeys, $actual, 'Activity structure does not match');
}
}

View File

@@ -9,7 +9,11 @@
namespace App\Tests\API;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\Query\VisibilityQuery;
use Symfony\Bundle\FrameworkBundle\Client;
/**
* @coversDefaultClass \App\API\ProjectController
@@ -34,6 +38,72 @@ class ProjectControllerTest extends APIControllerBaseTest
$this->assertStructure($result[0]);
}
protected function loadProjectTestData(Client $client)
{
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$customer = $em->getRepository(Customer::class)->find(1);
$customer2 = (new Customer())->setName('first one')->setVisible(false)->setCountry('de')->setTimezone('Europe/Berlin');
$em->persist($customer2);
$customer3 = (new Customer())->setName('second one')->setCountry('at')->setTimezone('Europe/Vienna');
$em->persist($customer3);
$project = (new Project())->setName('first')->setVisible(false)->setCustomer($customer2);
$em->persist($project);
$project = (new Project())->setName('second')->setVisible(false)->setCustomer($customer);
$em->persist($project);
$project = (new Project())->setName('third')->setVisible(true)->setCustomer($customer2);
$em->persist($project);
$project = (new Project())->setName('fourth')->setVisible(true)->setCustomer($customer3);
$em->persist($project);
$project = (new Project())->setName('fifth')->setVisible(true)->setCustomer($customer);
$em->persist($project);
$project = (new Project())->setName('sixth')->setVisible(false)->setCustomer($customer3);
$em->persist($project);
$em->flush();
}
/**
* @dataProvider getCollectionTestData
*/
public function testGetCollectionWithParams($url, $parameters, $expected)
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->loadProjectTestData($client);
$this->assertAccessIsGranted($client, $url, 'GET', $parameters);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertInternalType('array', $result);
$this->assertEquals(count($expected), count($result), 'Found wrong amount of projects');
for ($i = 0; $i < count($expected); $i++) {
$project = $result[$i];
$compare = $expected[$i];
$this->assertStructure($project, $compare[0]);
$this->assertEquals($compare[1], $project['customer_id']);
}
}
public function getCollectionTestData()
{
yield ['/api/projects', [], [[true, 1], [false, 3], [false, 1]]];
yield ['/api/projects', ['customer' => '1'], [[true, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[true, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false, 1]]];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_VISIBLE], []];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false, 2]]];
}
public function testGetEntity()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -49,12 +119,18 @@ class ProjectControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/projects/2');
}
protected function assertStructure(array $result)
protected function assertStructure(array $result, $complete = true)
{
$expectedKeys = [
'id', 'name', 'comment', 'visible', 'budget', 'order_number', 'customer_id'
];
if (!$complete) {
$expectedKeys = [
'id', 'name', 'visible', 'budget', 'customer_id'
];
}
$actual = array_keys($result);
$this->assertEquals(count($expectedKeys), count($actual), 'Project entity has different amount of keys');

View File

@@ -80,7 +80,9 @@ class TimesheetControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
$client->submit($form, [
'timesheet_edit_form' => [
'description' => 'Testing is fun!'
'description' => 'Testing is fun!',
'project' => 1,
'activity' => 1,
]
]);

View File

@@ -81,11 +81,12 @@ abstract class ControllerBaseTest extends WebTestCase
* @param Client $client
* @param string $url
* @param string $method
* @param array $parameters
* @return \Symfony\Component\DomCrawler\Crawler
*/
protected function request(Client $client, string $url, $method = 'GET')
protected function request(Client $client, string $url, $method = 'GET', array $parameters = [])
{
return $client->request($method, $this->createUrl($url));
return $client->request($method, $this->createUrl($url), $parameters);
}
/**
@@ -140,13 +141,14 @@ abstract class ControllerBaseTest extends WebTestCase
/**
* @param Client $client
* @param string $url
* @param $url
* @param string $method
* @param array $parameters
*/
protected function assertAccessIsGranted(Client $client, $url)
protected function assertAccessIsGranted(Client $client, $url, $method = 'GET', array $parameters = [])
{
$this->request($client, $url);
$this->request($client, $url, $method, $parameters);
$this->assertTrue($client->getResponse()->isSuccessful());
// TODO improve this test?
}
/**

View File

@@ -111,7 +111,9 @@ class TimesheetControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
$client->submit($form, [
'timesheet_edit_form' => [
'description' => 'Testing is fun!'
'description' => 'Testing is fun!',
'project' => 1,
'activity' => 1,
]
]);
@@ -157,7 +159,14 @@ class TimesheetControllerTest extends ControllerBaseTest
public function testStartAction()
{
$client = $this->getClientForAuthenticatedUser();
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures();
$fixture->setUser($this->getUserByRole($em, User::ROLE_USER));
$fixture->setAmount(1);
$this->importFixture($em, $fixture);
$this->request($client, '/timesheet/start/1');
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
@@ -167,10 +176,11 @@ class TimesheetControllerTest extends ControllerBaseTest
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$timesheet = $em->getRepository(Timesheet::class)->find(2);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId());
}
public function testStopAction()
@@ -184,6 +194,8 @@ class TimesheetControllerTest extends ControllerBaseTest
'timesheet_edit_form' => [
'description' => 'Testing is fun!',
'fixedRate' => 100,
'project' => 1,
'activity' => 1,
]
]);
@@ -218,6 +230,8 @@ class TimesheetControllerTest extends ControllerBaseTest
$client->submit($form, [
'timesheet_edit_form' => [
'hourlyRate' => 100,
'project' => 1,
'activity' => 1,
]
]);
@@ -250,6 +264,8 @@ class TimesheetControllerTest extends ControllerBaseTest
$client->submit($form, [
'timesheet_edit_form' => [
'hourlyRate' => 100,
'project' => 1,
'activity' => 1,
]
]);

View File

@@ -10,6 +10,7 @@
namespace App\Tests\DataFixtures;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
@@ -108,6 +109,8 @@ class TimesheetFixtures extends Fixture
$activities = $this->getAllActivities($manager);
}
$projects = $this->getAllProjects($manager);
$faker = Factory::create();
$user = $this->user;
@@ -118,9 +121,18 @@ class TimesheetFixtures extends Fixture
} elseif ($i % 2 == 0) {
$description = '';
}
$activity = $activities[array_rand($activities)];
$project = $activity->getProject();
if (null === $project) {
$project = $projects[array_rand($projects)];
}
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$activity,
$project,
$description,
$this->getDateTime($i)
);
@@ -129,11 +141,20 @@ class TimesheetFixtures extends Fixture
}
for ($i = 0; $i < $this->running; $i++) {
$activity = $activities[array_rand($activities)];
$project = $activity->getProject();
if (null === $project) {
$project = $projects[array_rand($projects)];
}
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$activity,
$project,
$faker->text,
$this->getDateTime($i)
$this->getDateTime($i),
false
);
$manager->persist($entry);
}
@@ -160,7 +181,7 @@ class TimesheetFixtures extends Fixture
protected function getAllActivities(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
/* @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -169,15 +190,32 @@ class TimesheetFixtures extends Fixture
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
*/
protected function getAllProjects(ObjectManager $manager)
{
$all = [];
/* @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param User $user
* @param Activity $activity
* @param $description
* @param Project $project
* @param string $description
* @param \DateTime $start
* @param bool $setEndDate
* @return Timesheet
*/
private function createTimesheetEntry(User $user, Activity $activity, $description, \DateTime $start, $setEndDate = true)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, \DateTime $start, $setEndDate = true)
{
$end = clone $start;
$end = $end->modify('+ ' . (rand(1, 172800)) . ' seconds');
@@ -188,6 +226,7 @@ class TimesheetFixtures extends Fixture
$entry = new Timesheet();
$entry
->setActivity($activity)
->setProject($project)
->setDescription($description)
->setUser($user)
->setRate(round(($duration / 3600) * $rate))

View File

@@ -35,14 +35,14 @@ abstract class AbstractEntityTest extends KernelTestCase
$expected = count($fieldNames);
$actual = $violations->count();
$this->assertEquals($expected, $actual, sprintf('Expected %s violations, found %s.', $expected, $actual));
$violatedFields = [];
/** @var ConstraintViolationInterface $validation */
foreach ($violations as $validation) {
$violatedFields[$validation->getPropertyPath()] = $validation->getPropertyPath();
}
$this->assertEquals($expected, count($violatedFields), sprintf('Expected %s violations, found %s in %s.', $expected, $actual, implode(', ', array_keys($violatedFields))));
foreach ($fieldNames as $id => $propertyPath) {
$foundField = false;
if (in_array($propertyPath, $violatedFields)) {

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Entity;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
/**
* @covers \App\Entity\Project
@@ -31,6 +32,7 @@ class ProjectTest extends AbstractEntityTest
// activities
$this->assertNull($sut->getFixedRate());
$this->assertNull($sut->getHourlyRate());
$this->assertNull($sut->getTimesheets());
}
public function testSetterAndGetter()
@@ -64,5 +66,9 @@ class ProjectTest extends AbstractEntityTest
$this->assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Project::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate());
$timesheets = [(new Timesheet())->setDescription('foo'), (new Timesheet())->setDescription('bar')];
$this->assertInstanceOf(Project::class, $sut->setTimesheets($timesheets));
$this->assertSame($timesheets, $sut->getTimesheets());
}
}

View File

@@ -29,6 +29,7 @@ class TimesheetTest extends AbstractEntityTest
$this->assertSame(0, $sut->getDuration());
$this->assertNull($sut->getUser());
$this->assertNull($sut->getActivity());
$this->assertNull($sut->getProject());
$this->assertNull($sut->getDescription());
$this->assertSame(0.00, $sut->getRate());
$this->assertNull($sut->getFixedRate());
@@ -56,10 +57,52 @@ class TimesheetTest extends AbstractEntityTest
$entity = new Timesheet();
$entity->setUser(new User());
$entity->setActivity($activity);
$entity->setProject($project);
return $entity;
}
public function testValidationNeedsActivity()
{
$entity = new Timesheet();
$entity
->setUser(new User())
->setProject(new Project())
->setBegin(new \DateTime())
;
$this->assertHasViolationForField($entity, 'activity');
}
public function testValidationNeedsProject()
{
$entity = new Timesheet();
$entity
->setUser(new User())
->setActivity(new Activity())
->setBegin(new \DateTime())
;
$this->assertHasViolationForField($entity, 'project');
}
public function testValidationProjectMismatch()
{
$project = (new Project())->setName('foo');
$project2 = (new Project())->setName('bar');
$activity = (new Activity())->setName('hello-world')->setProject($project);
$entity = new Timesheet();
$entity
->setUser(new User())
->setActivity($activity)
->setProject($project2)
->setBegin(new \DateTime())
;
$this->assertHasViolationForField($entity, 'project');
}
public function testValidationEndNotEarlierThanBegin()
{
$entity = $this->getEntity();

View File

@@ -72,7 +72,7 @@ abstract class AbstractCalculatorTest extends TestCase
->setRate(293.27)
->setUser(new User())
->setActivity((new Activity())->setName('foo'))
;
->setProject((new Project())->setName('bar'));
$model = new InvoiceModel();
$model->setCustomer($customer);

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Invoice\Calculator;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\Calculator\ShortInvoiceCalculator;
@@ -35,8 +36,12 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$template = new InvoiceTemplate();
$template->setVat(19);
$project = new Project();
$project->setName('sdfsdf');
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
@@ -44,6 +49,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
->setRate(293.27)
->setUser(new User())
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -54,6 +60,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
->setRate(84)
->setUser(new User())
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -64,6 +71,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
->setRate(111.11)
->setUser(new User())
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;

View File

@@ -104,6 +104,7 @@ abstract class AbstractRendererTest extends KernelTestCase
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -114,6 +115,7 @@ abstract class AbstractRendererTest extends KernelTestCase
->setRate(84.75)
->setUser($user2)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -124,6 +126,7 @@ abstract class AbstractRendererTest extends KernelTestCase
->setRate(111.11)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -134,6 +137,7 @@ abstract class AbstractRendererTest extends KernelTestCase
->setRate(1947.99)
->setUser($user2)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
@@ -144,6 +148,7 @@ abstract class AbstractRendererTest extends KernelTestCase
->setFixedRate(84)
->setUser((new User())->setUsername('kevin'))
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;

View File

@@ -40,5 +40,12 @@ class ActivityQueryTest extends BaseQueryTest
$sut->setProject($expected);
$this->assertEquals($expected, $sut->getProject());
// make sure int is allowed as well
$sut->setProject(99);
$this->assertEquals(99, $sut->getProject());
$sut->setCustomer(99);
$this->assertEquals(99, $sut->getCustomer());
}
}

View File

@@ -32,5 +32,9 @@ class ProjectQueryTest extends BaseQueryTest
$sut->setCustomer($expected);
$this->assertEquals($expected, $sut->getCustomer());
// make sure int is allowed as well
$sut->setCustomer(99);
$this->assertEquals(99, $sut->getCustomer());
}
}

View File

@@ -74,7 +74,7 @@ class TimesheetRepositoryTest extends AbstractRepositoryTest
$repository->stopRecording($entities[0]);
}
public function testStartAndStop()
public function testStopRecording()
{
$em = $this->getEntityManager();
$user = $this->getUserByRole($em, User::ROLE_USER);
@@ -82,16 +82,10 @@ class TimesheetRepositoryTest extends AbstractRepositoryTest
$fixtures = new TimesheetFixtures();
$fixtures->setUser($user);
$fixtures->setAmount(1);
$fixtures->setAmountRunning(1);
$this->importFixture($em, $fixtures);
$query = new TimesheetQuery();
$query->setResultType(BaseQuery::RESULT_TYPE_OBJECTS);
$entities = $repository->findByQuery($query);
$activity = $entities[0]->getActivity();
$user = $this->getUserByRole($em, User::ROLE_USER);
$timesheet = $repository->startRecording($user, $activity);
$timesheet = $repository->find(1);
$this->assertInstanceOf(Timesheet::class, $timesheet);
$this->assertNull($timesheet->getEnd());

View File

@@ -120,6 +120,7 @@ class RateCalculatorTest extends TestCase
->setHourlyRate($timesheetHourly)
->setFixedRate($timesheetFixed)
->setActivity($activity)
->setProject($project)
->setDuration($duration)
->setUser($this->getTestUser($userRate))
;

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Voter;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Timesheet;
use App\Entity\User;
@@ -26,9 +27,9 @@ class TimesheetVoterTest extends TestCase
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $allow, $subject, $attributes, $result)
public function testVote($user, $roles, $allow, $subject, $attributes, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles());
$token = new UsernamePasswordToken($user, 'foo', 'bar', $roles);
$accessManager = $this->getMockBuilder(AclDecisionManager::class)->disableOriginalConstructor()->getMock();
$accessManager->method('isFullyAuthenticated')->willReturn($allow);
@@ -43,13 +44,17 @@ class TimesheetVoterTest extends TestCase
{
$user0 = $this->getUser(0, User::ROLE_CUSTOMER);
$user1 = $this->getUser(1, User::ROLE_USER);
$user2 = $this->getUser(1, User::ROLE_TEAMLEAD);
$user2 = $this->getUser(2, User::ROLE_TEAMLEAD);
return [
[$user0, false, new Customer(), [TimesheetVoter::EDIT], VoterInterface::ACCESS_ABSTAIN],
[$user1, false, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED],
[$user1, false, $this->getTimesheet($user0), [TimesheetVoter::EDIT], VoterInterface::ACCESS_DENIED],
[$user2, true, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED],
[$user0, $user0->getRoles(), false, new Customer(), [TimesheetVoter::EDIT], VoterInterface::ACCESS_ABSTAIN],
[$user1, $user1->getRoles(), false, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED],
[$user1, $user1->getRoles(), false, $this->getTimesheet($user0), [TimesheetVoter::EDIT], VoterInterface::ACCESS_DENIED],
[$user2, $user2->getRoles(), true, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_GRANTED],
['foo', [], false, $this->getTimesheet($user1), [TimesheetVoter::EDIT], VoterInterface::ACCESS_DENIED],
[$user2, $user2->getRoles(), true, new Activity(), [TimesheetVoter::EDIT], VoterInterface::ACCESS_ABSTAIN],
[$user2, $user2->getRoles(), true, $this->getTimesheet($user2), [TimesheetVoter::VIEW], VoterInterface::ACCESS_GRANTED],
[$user1, $user1->getRoles(), false, $this->getTimesheet($user2), [TimesheetVoter::VIEW], VoterInterface::ACCESS_DENIED],
];
}
@@ -61,6 +66,11 @@ class TimesheetVoterTest extends TestCase
return $timesheet;
}
/**
* @param $id
* @param $role
* @return User
*/
protected function getUser($id, $role)
{
$user = $this->getMockBuilder(User::class)->getMock();

Binary file not shown.

View File

@@ -35,3 +35,10 @@ Further readings:
- [MariaDB - JSON support was added with 10.2.7](https://mariadb.com/kb/en/library/json-data-type/)
- [Using JSON fields with Doctrine ORM on PostgreSQL & MySQL](https://symfony.fi/entry/using-json-fields-with-doctrine-orm-on-postgresql-mysql)
## Why is SQLite not recommended for production usage
SQLite is a great database engine for testing, but when it comes to production usage it fails due to several reasons:
- It does not support ALTER TABLE commands and makes update procedures very clunky and problematic/errorsome (we still try to support updates, but they are heavy on large databases)
- It does not support FOREIGN KEY constraints out of the box, which can lead to critical bugs when deleting activities/projects/customers