From 817a43b17842867eb69d496ab3508ed04e6ea251 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Sat, 23 Nov 2019 22:02:55 +0100 Subject: [PATCH] added csv importer for timesheet and project data (#1216) --- composer.json | 1 + composer.lock | 297 ++++++---- src/Command/ImportProjectCommand.php | 338 ++++++++++++ src/Command/ImportTimesheetCommand.php | 515 ++++++++++++++++++ src/Importer/InvalidFieldsException.php | 28 + src/Importer/UnknownUserException.php | 31 ++ symfony.lock | 3 + tests/Importer/InvalidFieldsExceptionTest.php | 26 + tests/Importer/UnknownUserExceptionTest.php | 26 + 9 files changed, 1158 insertions(+), 107 deletions(-) create mode 100644 src/Command/ImportProjectCommand.php create mode 100644 src/Command/ImportTimesheetCommand.php create mode 100644 src/Importer/InvalidFieldsException.php create mode 100644 src/Importer/UnknownUserException.php create mode 100644 tests/Importer/InvalidFieldsExceptionTest.php create mode 100644 tests/Importer/UnknownUserExceptionTest.php diff --git a/composer.json b/composer.json index e6d4e1fc..5f200594 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "kevinpapst/adminlte-bundle": "~3.0", "kimai/kimai2-composer": "^0.1", "laravolt/avatar": "^3.0", + "league/csv": "^9.4", "mpdf/mpdf": "^7.1", "nelmio/api-doc-bundle": "^3.2", "nelmio/cors-bundle": "^1.5", diff --git a/composer.lock b/composer.lock index e3453399..af1cb182 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "348a459e96d09528ed01477c95c8dd6a", + "content-hash": "89fb45fe0706c25775a066f5f6138d62", "packages": [ { "name": "beberlei/doctrineextensions", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/beberlei/DoctrineExtensions.git", - "reference": "a8747a2aef34f602c365cf2efd1abc94c5ae7e0a" + "reference": "5d5a7259b7d44f6d9d5ced4c09f9db6b7f460139" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/beberlei/DoctrineExtensions/zipball/a8747a2aef34f602c365cf2efd1abc94c5ae7e0a", - "reference": "a8747a2aef34f602c365cf2efd1abc94c5ae7e0a", + "url": "https://api.github.com/repos/beberlei/DoctrineExtensions/zipball/5d5a7259b7d44f6d9d5ced4c09f9db6b7f460139", + "reference": "5d5a7259b7d44f6d9d5ced4c09f9db6b7f460139", "shasum": "" }, "require": { @@ -58,7 +58,7 @@ "doctrine", "orm" ], - "time": "2019-07-25T15:08:53+00:00" + "time": "2019-11-22T14:30:56+00:00" }, { "name": "behat/transliterator", @@ -502,16 +502,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "1.11.2", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "28101e20776d8fa20a00b54947fbae2db0d09103" + "reference": "a374f3bc54fe7c23ddb722ef6b9ddd2d9dcb9115" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/28101e20776d8fa20a00b54947fbae2db0d09103", - "reference": "28101e20776d8fa20a00b54947fbae2db0d09103", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/a374f3bc54fe7c23ddb722ef6b9ddd2d9dcb9115", + "reference": "a374f3bc54fe7c23ddb722ef6b9ddd2d9dcb9115", "shasum": "" }, "require": { @@ -519,11 +519,11 @@ "doctrine/doctrine-cache-bundle": "~1.2", "jdorn/sql-formatter": "^1.2.16", "php": "^7.1", - "symfony/config": "^3.4|^4.1", - "symfony/console": "^3.4|^4.1", - "symfony/dependency-injection": "^3.4|^4.1", - "symfony/doctrine-bridge": "^3.4|^4.1", - "symfony/framework-bundle": "^3.4|^4.1" + "symfony/config": "^3.4.30|^4.3.3", + "symfony/console": "^3.4.30|^4.3.3", + "symfony/dependency-injection": "^3.4.30|^4.3.3", + "symfony/doctrine-bridge": "^3.4.30|^4.3.3", + "symfony/framework-bundle": "^3.4.30|^4.3.3" }, "conflict": { "doctrine/orm": "<2.6", @@ -533,14 +533,15 @@ "doctrine/coding-standard": "^6.0", "doctrine/orm": "^2.6", "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "7.0", - "symfony/cache": "^3.4|^4.1", + "phpunit/phpunit": "^7.5", + "symfony/cache": "^3.4.30|^4.3.3", "symfony/phpunit-bridge": "^4.2", - "symfony/property-info": "^3.4|^4.1", - "symfony/validator": "^3.4|^4.1", - "symfony/web-profiler-bundle": "^3.4|^4.1", - "symfony/yaml": "^3.4|^4.1", - "twig/twig": "^1.34|^2.4" + "symfony/property-info": "^3.4.30|^4.3.3", + "symfony/twig-bridge": "^3.4|^4.1", + "symfony/validator": "^3.4.30|^4.3.3", + "symfony/web-profiler-bundle": "^3.4.30|^4.3.3", + "symfony/yaml": "^3.4.30|^4.3.3", + "twig/twig": "^1.34|^2.12" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -549,7 +550,7 @@ "type": "symfony-bundle", "extra": { "branch-alias": { - "dev-master": "1.11.x-dev" + "dev-master": "1.12.x-dev" } }, "autoload": { @@ -563,20 +564,20 @@ ], "authors": [ { - "name": "Symfony Community", - "homepage": "http://symfony.com/contributors" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Benjamin Eberlei", "email": "kontakt@beberlei.de" }, { - "name": "Doctrine Project", - "homepage": "http://www.doctrine-project.org/" + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" }, { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org/" } ], "description": "Symfony DoctrineBundle", @@ -587,7 +588,7 @@ "orm", "persistence" ], - "time": "2019-06-04T07:35:05+00:00" + "time": "2019-11-19T11:42:20+00:00" }, { "name": "doctrine/doctrine-cache-bundle", @@ -1093,33 +1094,35 @@ }, { "name": "doctrine/orm", - "version": "v2.6.4", + "version": "v2.7.0", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "b52ef5a1002f99ab506a5a2d6dba5a2c236c5f43" + "reference": "4d763ca4c925f647b248b9fa01b5f47aa3685d62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/b52ef5a1002f99ab506a5a2d6dba5a2c236c5f43", - "reference": "b52ef5a1002f99ab506a5a2d6dba5a2c236c5f43", + "url": "https://api.github.com/repos/doctrine/orm/zipball/4d763ca4c925f647b248b9fa01b5f47aa3685d62", + "reference": "4d763ca4c925f647b248b9fa01b5f47aa3685d62", "shasum": "" }, "require": { - "doctrine/annotations": "~1.5", - "doctrine/cache": "~1.6", - "doctrine/collections": "^1.4", - "doctrine/common": "^2.7.1", - "doctrine/dbal": "^2.6", - "doctrine/instantiator": "~1.1", + "doctrine/annotations": "^1.8", + "doctrine/cache": "^1.9.1", + "doctrine/collections": "^1.5", + "doctrine/common": "^2.11", + "doctrine/dbal": "^2.9.3", + "doctrine/event-manager": "^1.1", + "doctrine/instantiator": "^1.3", + "doctrine/persistence": "^1.2", "ext-pdo": "*", "php": "^7.1", - "symfony/console": "~3.0|~4.0" + "symfony/console": "^3.0|^4.0|^5.0" }, "require-dev": { "doctrine/coding-standard": "^5.0", "phpunit/phpunit": "^7.5", - "symfony/yaml": "~3.4|~4.0" + "symfony/yaml": "^3.4|^4.0|^5.0" }, "suggest": { "symfony/yaml": "If you want to use YAML Metadata Mapping Driver" @@ -1130,7 +1133,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.6.x-dev" + "dev-master": "2.7.x-dev" } }, "autoload": { @@ -1165,12 +1168,12 @@ } ], "description": "Object-Relational-Mapper for PHP", - "homepage": "http://www.doctrine-project.org", + "homepage": "https://www.doctrine-project.org/projects/orm.html", "keywords": [ "database", "orm" ], - "time": "2019-09-20T14:30:26+00:00" + "time": "2019-11-19T08:38:05+00:00" }, { "name": "doctrine/persistence", @@ -2638,7 +2641,7 @@ }, { "name": "illuminate/cache", - "version": "v6.5.1", + "version": "v6.5.2", "source": { "type": "git", "url": "https://github.com/illuminate/cache.git", @@ -2689,7 +2692,7 @@ }, { "name": "illuminate/contracts", - "version": "v6.5.1", + "version": "v6.5.2", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -2733,16 +2736,16 @@ }, { "name": "illuminate/support", - "version": "v6.5.1", + "version": "v6.5.2", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "236fa24d2e510c4800429cde89ed72e535051a5d" + "reference": "7a93f9e5a15bbf41236a8695fb738bf293f3c186" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/236fa24d2e510c4800429cde89ed72e535051a5d", - "reference": "236fa24d2e510c4800429cde89ed72e535051a5d", + "url": "https://api.github.com/repos/illuminate/support/zipball/7a93f9e5a15bbf41236a8695fb738bf293f3c186", + "reference": "7a93f9e5a15bbf41236a8695fb738bf293f3c186", "shasum": "" }, "require": { @@ -2790,7 +2793,7 @@ ], "description": "The Illuminate Support package.", "homepage": "https://laravel.com", - "time": "2019-11-04T13:46:29+00:00" + "time": "2019-11-18T21:49:40+00:00" }, { "name": "intervention/image", @@ -3308,6 +3311,75 @@ ], "time": "2019-09-04T14:39:26+00:00" }, + { + "name": "league/csv", + "version": "9.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/csv.git", + "reference": "bf83acc23a7d60978fce36a384f97bf9d7d2ea0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/bf83acc23a7d60978fce36a384f97bf9d7d2ea0c", + "reference": "bf83acc23a7d60978fce36a384f97bf9d7d2ea0c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.0.10" + }, + "require-dev": { + "ext-curl": "*", + "friendsofphp/php-cs-fixer": "^2.12", + "phpstan/phpstan": "^0.9.2", + "phpstan/phpstan-phpunit": "^0.9.4", + "phpstan/phpstan-strict-rules": "^0.9.0", + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Csv\\": "src" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "Csv data manipulation made easy in PHP", + "homepage": "http://csv.thephpleague.com", + "keywords": [ + "csv", + "export", + "filter", + "import", + "read", + "write" + ], + "time": "2019-10-17T06:05:32+00:00" + }, { "name": "markbaker/complex", "version": "1.4.7", @@ -3668,16 +3740,16 @@ }, { "name": "nelmio/api-doc-bundle", - "version": "v3.4.0", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git", - "reference": "279d20be784b9879ad0a12859efa3a301404f06c" + "reference": "f596adfb4d16e65d1a1941f907092a119d4c76cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/279d20be784b9879ad0a12859efa3a301404f06c", - "reference": "279d20be784b9879ad0a12859efa3a301404f06c", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/f596adfb4d16e65d1a1941f907092a119d4c76cb", + "reference": "f596adfb4d16e65d1a1941f907092a119d4c76cb", "shasum": "" }, "require": { @@ -3689,13 +3761,17 @@ "symfony/property-info": "^3.4|^4.0", "zircote/swagger-php": "^2.0.9" }, + "conflict": { + "symfony/framework-bundle": "4.2.7" + }, "require-dev": { "api-platform/core": "^2.1.0", "doctrine/annotations": "^1.2", "doctrine/common": "^2.4", "friendsofsymfony/rest-bundle": "^2.0", + "jms/serializer": "^1.14|^3.0", "jms/serializer-bundle": "^2.0|^3.0", - "sensio/framework-extra-bundle": "^3.0", + "sensio/framework-extra-bundle": "^3.0.13|^4.0|^5.0", "symfony/asset": "^3.4|^4.0", "symfony/browser-kit": "^3.4|^4.0", "symfony/cache": "^3.4|^4.0", @@ -3703,8 +3779,9 @@ "symfony/console": "^3.4|^4.0", "symfony/dom-crawler": "^3.4|^4.0", "symfony/form": "^3.4|^4.0", - "symfony/phpunit-bridge": "^3.4|^4.0", + "symfony/phpunit-bridge": "^3.4.24|^4.0", "symfony/property-access": "^3.4|^4.0", + "symfony/routing": "^3.4|^4.0", "symfony/stopwatch": "^3.4|^4.0", "symfony/templating": "^3.4|^4.0", "symfony/twig-bundle": "^3.4|^4.0", @@ -3747,7 +3824,7 @@ "documentation", "rest" ], - "time": "2019-02-18T16:27:10+00:00" + "time": "2019-11-21T17:18:16+00:00" }, { "name": "nelmio/cors-bundle", @@ -3809,22 +3886,22 @@ }, { "name": "nesbot/carbon", - "version": "2.26.0", + "version": "2.27.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "e01ecc0b71168febb52ae1fdc1cfcc95428e604e" + "reference": "13b8485a8690f103bf19cba64879c218b102b726" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e01ecc0b71168febb52ae1fdc1cfcc95428e604e", - "reference": "e01ecc0b71168febb52ae1fdc1cfcc95428e604e", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/13b8485a8690f103bf19cba64879c218b102b726", + "reference": "13b8485a8690f103bf19cba64879c218b102b726", "shasum": "" }, "require": { "ext-json": "*", "php": "^7.1.8 || ^8.0", - "symfony/translation": "^3.4 || ^4.0" + "symfony/translation": "^3.4 || ^4.0 || ^5.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.14 || ^3.0", @@ -3839,6 +3916,9 @@ ], "type": "library", "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + }, "laravel": { "providers": [ "Carbon\\Laravel\\ServiceProvider" @@ -3872,7 +3952,7 @@ "datetime", "time" ], - "time": "2019-10-21T21:32:25+00:00" + "time": "2019-11-20T06:59:06+00:00" }, { "name": "ocramius/package-versions", @@ -4351,16 +4431,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.9.0", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "8dea03eaf60a349b6097e4bcad11f894668280df" + "reference": "f734783d826bd84c3d54fcf7b71c37ab9bac4b04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/8dea03eaf60a349b6097e4bcad11f894668280df", - "reference": "8dea03eaf60a349b6097e4bcad11f894668280df", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/f734783d826bd84c3d54fcf7b71c37ab9bac4b04", + "reference": "f734783d826bd84c3d54fcf7b71c37ab9bac4b04", "shasum": "" }, "require": { @@ -4378,19 +4458,19 @@ "ext-zip": "*", "ext-zlib": "*", "markbaker/complex": "^1.4", - "markbaker/matrix": "^1.1", + "markbaker/matrix": "^1.2", "php": "^7.1", "psr/simple-cache": "^1.0" }, "require-dev": { - "dompdf/dompdf": "^0.8.0", - "friendsofphp/php-cs-fixer": "@stable", + "dompdf/dompdf": "^0.8.3", + "friendsofphp/php-cs-fixer": "^2.16", "jpgraph/jpgraph": "^4.0", - "mpdf/mpdf": "^7.0.0", - "phpcompatibility/php-compatibility": "^8.0", + "mpdf/mpdf": "^8.0", + "phpcompatibility/php-compatibility": "^9.3", "phpunit/phpunit": "^7.5", - "squizlabs/php_codesniffer": "^3.3", - "tecnickcom/tcpdf": "^6.2" + "squizlabs/php_codesniffer": "^3.5", + "tecnickcom/tcpdf": "^6.3" }, "suggest": { "dompdf/dompdf": "Option for rendering PDF with PDF Writer", @@ -4406,7 +4486,7 @@ }, "notification-url": "https://packagist.org/downloads/", "license": [ - "LGPL-2.1-or-later" + "MIT" ], "authors": [ { @@ -4440,7 +4520,7 @@ "xls", "xlsx" ], - "time": "2019-08-17T22:24:35+00:00" + "time": "2019-11-18T11:33:05+00:00" }, { "name": "phpoffice/phpword", @@ -8941,26 +9021,29 @@ }, { "name": "white-october/pagerfanta-bundle", - "version": "v1.2.4", + "version": "v1.3.1", "source": { "type": "git", "url": "https://github.com/whiteoctober/WhiteOctoberPagerfantaBundle.git", - "reference": "19bf14bf8c72e4205c9ca97028436701fe3991a6" + "reference": "5421d5e79b72495b34b745d37e4d341e6fa2190d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/whiteoctober/WhiteOctoberPagerfantaBundle/zipball/19bf14bf8c72e4205c9ca97028436701fe3991a6", - "reference": "19bf14bf8c72e4205c9ca97028436701fe3991a6", + "url": "https://api.github.com/repos/whiteoctober/WhiteOctoberPagerfantaBundle/zipball/5421d5e79b72495b34b745d37e4d341e6fa2190d", + "reference": "5421d5e79b72495b34b745d37e4d341e6fa2190d", "shasum": "" }, "require": { "pagerfanta/pagerfanta": "^1.1.0|^2.0.0", - "php": ">=5.3", + "php": ">=5.3.3", "symfony/framework-bundle": "~2.3|~3.0|~4.0", "symfony/property-access": "~2.3|~3.0|~4.0", "symfony/translation": "~2.3|~3.0|~4.0", "symfony/twig-bundle": "~2.3|~3.0|~4.0" }, + "conflict": { + "twig/twig": "<1.34|>=2.0,<2.4" + }, "require-dev": { "phpunit/phpunit": "~3.7|~4.0|^5.0", "symfony/symfony": "~2.3|~3.0|~4.0" @@ -8995,7 +9078,7 @@ "page", "paging" ], - "time": "2019-02-14T08:42:52+00:00" + "time": "2019-11-21T17:13:04+00:00" }, { "name": "willdurand/jsonp-callback-validator", @@ -10062,16 +10145,16 @@ }, { "name": "nette/php-generator", - "version": "v3.2.3", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/nette/php-generator.git", - "reference": "aea6e81437bb238e5f0e5b5ce06337433908e63b" + "reference": "4240fd7adf499138c07b814ef9b9a6df9f6d7187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/aea6e81437bb238e5f0e5b5ce06337433908e63b", - "reference": "aea6e81437bb238e5f0e5b5ce06337433908e63b", + "url": "https://api.github.com/repos/nette/php-generator/zipball/4240fd7adf499138c07b814ef9b9a6df9f6d7187", + "reference": "4240fd7adf499138c07b814ef9b9a6df9f6d7187", "shasum": "" }, "require": { @@ -10085,7 +10168,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-master": "3.3-dev" } }, "autoload": { @@ -10117,7 +10200,7 @@ "php", "scaffolding" ], - "time": "2019-07-05T13:01:56+00:00" + "time": "2019-11-22T11:12:11+00:00" }, { "name": "nette/robot-loader", @@ -10897,16 +10980,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "7.0.8", + "version": "7.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "aa0d179a13284c7420fc281fc32750e6cc7c9e2f" + "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa0d179a13284c7420fc281fc32750e6cc7c9e2f", - "reference": "aa0d179a13284c7420fc281fc32750e6cc7c9e2f", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f1884187926fbb755a9aaf0b3836ad3165b478bf", + "reference": "f1884187926fbb755a9aaf0b3836ad3165b478bf", "shasum": "" }, "require": { @@ -10956,7 +11039,7 @@ "testing", "xunit" ], - "time": "2019-09-17T06:24:36+00:00" + "time": "2019-11-20T13:55:58+00:00" }, { "name": "phpunit/php-file-iterator", @@ -11397,16 +11480,16 @@ }, { "name": "sebastian/environment", - "version": "4.2.2", + "version": "4.2.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404" + "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404", - "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/464c90d7bdf5ad4e8a6aea15c091fec0603d4368", + "reference": "464c90d7bdf5ad4e8a6aea15c091fec0603d4368", "shasum": "" }, "require": { @@ -11446,7 +11529,7 @@ "environment", "hhvm" ], - "time": "2019-05-05T09:05:15+00:00" + "time": "2019-11-20T08:46:58+00:00" }, { "name": "sebastian/exporter", @@ -12088,16 +12171,16 @@ }, { "name": "symfony/phpunit-bridge", - "version": "v4.3.8", + "version": "v4.4.0", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "c216b32261358a820bb4217eb3a20e3f437a484e" + "reference": "7daa9d4a2f457f9cae32f1c2406de1921dc86a79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/c216b32261358a820bb4217eb3a20e3f437a484e", - "reference": "c216b32261358a820bb4217eb3a20e3f437a484e", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/7daa9d4a2f457f9cae32f1c2406de1921dc86a79", + "reference": "7daa9d4a2f457f9cae32f1c2406de1921dc86a79", "shasum": "" }, "require": { @@ -12107,7 +12190,7 @@ "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0" }, "suggest": { - "symfony/debug": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" + "symfony/error-handler": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" }, "bin": [ "bin/simple-phpunit" @@ -12115,7 +12198,7 @@ "type": "symfony-bridge", "extra": { "branch-alias": { - "dev-master": "4.3-dev" + "dev-master": "4.4-dev" }, "thanks": { "name": "phpunit/phpunit", @@ -12149,7 +12232,7 @@ ], "description": "Symfony PHPUnit Bridge", "homepage": "https://symfony.com", - "time": "2019-10-30T12:58:49+00:00" + "time": "2019-11-08T15:59:14+00:00" }, { "name": "symfony/process", diff --git a/src/Command/ImportProjectCommand.php b/src/Command/ImportProjectCommand.php new file mode 100644 index 00000000..8ee7c960 --- /dev/null +++ b/src/Command/ImportProjectCommand.php @@ -0,0 +1,338 @@ +customers = $customers; + $this->projects = $projects; + $this->teams = $teams; + $this->users = $users; + $this->configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName(self::$defaultName) + ->setDescription('Import projects from CSV file') + ->setHelp( + 'This command allows to import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL . + 'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL . + 'Required column names: ' . implode(', ', self::$requiredHeader) . PHP_EOL . + 'Supported column names: ' . implode(', ', self::$supportedHeader) . PHP_EOL + ) + ->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned') + ->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',') + ->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers and projects. %s will be replaced with the current datetime', 'Imported at %s') + ->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $io->title('Kimai importer: Projects'); + + $csvFile = $input->getArgument('file'); + if (!file_exists($csvFile)) { + $io->error('File not existing: ' . $csvFile); + + return 1; + } + + if (!is_readable($csvFile)) { + $io->error('File cannot be read: ' . $csvFile); + + return 2; + } + + $this->dateTime = (new \DateTime())->format('Y.m.d H:i'); + $this->comment = sprintf($input->getOption('comment'), $this->dateTime); + + $csv = Reader::createFromPath($csvFile, 'r'); + $csv->setDelimiter($input->getOption('delimiter')); + $csv->setHeaderOffset(0); + $header = $csv->getHeader(); + + // validate teamlead + $teamlead = $input->getOption('teamlead'); + if (null !== $teamlead) { + $tmpUser = $this->users->findOneBy(['username' => $teamlead]); + if (null === $tmpUser) { + $tmpUser = $this->users->findOneBy(['email' => $teamlead]); + if (null === $tmpUser) { + $io->error( + sprintf( + 'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL . + 'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL, + $teamlead + ) + ); + + return 3; + } + } + + $teamlead = $tmpUser; + } + + if (!$this->validateHeader($header)) { + $io->error( + sprintf( + 'Found invalid CSV header: %s' . PHP_EOL . + 'Required fields: %s' . PHP_EOL . + 'All supported fields: %s' . PHP_EOL, + implode(', ', $header), + implode(', ', self::$requiredHeader), + implode(', ', self::$supportedHeader) + ) + ); + + return 4; + } + + $records = $csv->getRecords(); + + $doImport = true; + $row = 1; + $errors = 0; + + foreach ($records as $record) { + try { + $this->validateRow($record); + } catch (InvalidFieldsException $ex) { + $io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields()))); + $doImport = false; + $errors++; + } + + $row++; + } + + if (!$doImport) { + $io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors)); + + return 5; + } + + $row = 0; + foreach ($records as $record) { + $row++; + try { + $customer = $this->getCustomer($record['Customer']); + $projectName = $record['Name']; + + $project = new Project(); + $project->setName($projectName); + $project->setCustomer($customer); + + $comment = $this->comment; + if (isset($record['Comment']) && !empty($record['Comment'])) { + $comment = $record['Comment']; + } + $project->setComment($comment); + + if (isset($record['OrderNumber']) && !empty($record['OrderNumber'])) { + $project->setOrderNumber($record['OrderNumber']); + } + if (isset($record['OrderDate']) && !empty($record['OrderDate'])) { + $project->setOrderDate($record['OrderDate']); + } + + $team = null; + if (null !== $teamlead) { + $team = new Team(); + $team->setName($projectName); + $team->setTeamLead($teamlead); + + $this->teams->saveTeam($team); + } + + $this->projects->saveProject($project); + + if (null !== $team) { + $project->addTeam($team); + $team->addProject($project); + + $this->teams->saveTeam($team); + $this->projects->saveProject($project); + } + } catch (\Exception $ex) { + $io->error(sprintf('Failed importing project row %s with: %s', $row, $ex->getMessage())); + + return 6; + } + } + + $io->success(sprintf('Imported %s rows', $row)); + + return 0; + } + + private function getCustomer(string $customerName): Customer + { + if (!array_key_exists($customerName, $this->customerCache)) { + $tmpCustomer = $this->customers->findBy(['name' => $customerName]); + + if (count($tmpCustomer) > 1) { + throw new \Exception(sprintf('Found multiple customers with the name: %s', $customerName)); + } elseif (count($tmpCustomer) === 1) { + $tmpCustomer = $tmpCustomer[0]; + } + + if ($tmpCustomer instanceof Customer) { + $this->customerCache[$customerName] = $tmpCustomer; + } + } + + if (array_key_exists($customerName, $this->customerCache)) { + return $this->customerCache[$customerName]; + } + + $customer = new Customer(); + $customer->setName(sprintf($customerName, $this->dateTime)); + $customer->setComment($this->comment); + $customer->setCountry($this->configuration->getCustomerDefaultCountry()); + $timezone = date_default_timezone_get(); + if (null !== $this->configuration->getCustomerDefaultTimezone()) { + $timezone = $this->configuration->getCustomerDefaultTimezone(); + } + $customer->setTimezone($timezone); + + $this->customers->saveCustomer($customer); + + $this->customerCache[$customerName] = $customer; + + return $customer; + } + + /** + * @param array $row + * @return bool + * @throws InvalidFieldsException + */ + private function validateRow(array $row) + { + $fields = []; + + foreach (self::$requiredHeader as $headerName) { + if (!isset($row[$headerName]) || empty($row[$headerName])) { + $fields[] = $headerName; + } + } + + if (!empty($fields)) { + throw new InvalidFieldsException($fields); + } + + return true; + } + + private function validateHeader(array $header) + { + $fields = []; + + foreach (self::$requiredHeader as $headerName) { + if (!in_array($headerName, $header)) { + $fields[] = $headerName; + } + } + + return empty($fields); + } +} diff --git a/src/Command/ImportTimesheetCommand.php b/src/Command/ImportTimesheetCommand.php new file mode 100644 index 00000000..6c55466f --- /dev/null +++ b/src/Command/ImportTimesheetCommand.php @@ -0,0 +1,515 @@ +customers = $customers; + $this->projects = $projects; + $this->activities = $activities; + $this->users = $users; + $this->timesheets = $timesheets; + $this->configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName(self::$defaultName) + ->setDescription('Import timesheets from CSV file') + ->setHelp( + 'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL . + 'Imported customer, projects and activities will be matched by name.' . PHP_EOL . + 'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL + ) + ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user') + ->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects') + ->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project') + ->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',') + ->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN) + ->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Imported at %s') + ->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $io->title('Kimai importer: Timesheets'); + + $csvFile = $input->getArgument('file'); + if (!file_exists($csvFile)) { + $io->error('File not existing: ' . $csvFile); + + return 1; + } + + if (!is_readable($csvFile)) { + $io->error('File cannot be read: ' . $csvFile); + + return 2; + } + + $this->dateTime = (new \DateTime())->format('Y.m.d H:i'); + $this->comment = sprintf($input->getOption('comment'), $this->dateTime); + $this->begin = $input->getOption('begin'); + + $timezone = $input->getOption('timezone'); + switch ($timezone) { + case 'server': + $timezone = new \DateTimeZone(date_default_timezone_get()); + break; + + case 'user': + // null means fetch from user + $timezone = null; + break; + + default: + try { + $timezone = new \DateTimeZone($timezone); + } catch (\Exception $ex) { + $io->error('Invalid timezone given, import canceled.'); + + return 3; + } + break; + } + + $activityType = $input->getOption('activity'); + $allowedActivityTypes = ['project', 'global']; + if (!in_array($activityType, $allowedActivityTypes)) { + $io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes))); + + return 4; + } + + $csv = Reader::createFromPath($csvFile, 'r'); + $csv->setDelimiter($input->getOption('delimiter')); + $csv->setHeaderOffset(0); + $header = $csv->getHeader(); + if (!$this->validateHeader($header)) { + $io->error( + sprintf( + 'Found invalid CSV. The header: ' . PHP_EOL . + '%s' . PHP_EOL . + 'did not match the expected structure: ' . PHP_EOL . + '%s', + implode(', ', $header), + implode(', ', self::$supportedHeader) + ) + ); + + return 5; + } + + $records = $csv->getRecords(); + + $doImport = true; + $row = 1; + $errors = 0; + + // ======================= validate rows ======================= + foreach ($records as $record) { + try { + $this->validateRow($record); + } catch (InvalidFieldsException $ex) { + $io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields()))); + $doImport = false; + $errors++; + } + + try { + $user = $this->getUser($record['User']); + } catch (\Exception $ex) { + $io->error(sprintf('Unknown user %s in row %s', $record['User'], $row)); + $doImport = false; + $errors++; + } + + $row++; + } + + if (!$doImport) { + $io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors)); + + return 5; + } + + $durationParser = new Duration(); + $row = 0; + foreach ($records as $record) { + $row++; + try { + $project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer')); + $activity = $this->getActivity($record['Activity'], $project, $activityType); + $user = $this->getUser($record['User']); + + $begin = null; + $end = null; + $duration = 0; + + if (!empty($record['Duration'])) { + if (is_int($record['Duration'])) { + $duration = $record['Duration']; + } else { + $duration = $durationParser->parseDurationString($record['Duration']); + } + } + + if (null === $timezone) { + $timezone = new \DateTimeZone($user->getTimezone()); + } + + if (empty($record['From']) && empty($record['To'])) { + $begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone); + $end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration); + } elseif (empty($record['From'])) { + $end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone); + $begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration); + } elseif (empty($record['To'])) { + $begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone); + $end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration); + } else { + $begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone); + $end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone); + } + + $timesheet = new Timesheet(); + $timesheet->setActivity($activity); + $timesheet->setProject($project); + $timesheet->setBegin($begin); + $timesheet->setEnd($end); + $timesheet->setUser($user); + $timesheet->setDescription($record['Description']); + $timesheet->setExported((bool) $record['Exported']); + + if (!empty($record['Tags'])) { + foreach (explode(',', $record['Tags']) as $tag) { + if (empty($tag)) { + continue; + } + $timesheet->addTag((new Tag())->setName($tag)); + } + } + + if (!empty($record['Rate'])) { + $timesheet->setRate($record['Rate']); + } + if (!empty($record['Hourly rate'])) { + $timesheet->setHourlyRate($record['Hourly rate']); + } + if (!empty($record['Fixed rate'])) { + $timesheet->setFixedRate($record['Fixed rate']); + } + + $this->timesheets->save($timesheet); + } catch (\Exception $ex) { + $io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage())); + + return 6; + } + } + + $io->success(sprintf('Imported %s rows', $row)); + + return 0; + } + + private function getUser($user): User + { + if (!array_key_exists($user, $this->userCache)) { + $tmpUser = $this->users->findOneBy(['username' => $user]); + if (null === $tmpUser) { + $tmpUser = $this->users->findOneBy(['email' => $user]); + if (null === $tmpUser) { + throw new UnknownUserException($user); + } + } + $this->userCache[$user] = $tmpUser; + } + + return $this->userCache[$user]; + } + + private function getActivity($activity, Project $project, $activityType): Activity + { + $tmpActivity = null; + + $tmpActivities = $this->activities->findBy(['project' => $project, 'name' => $activity]); + + if (count($tmpActivities) === 0) { + $tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]); + } elseif (count($tmpActivities) === 1) { + $tmpActivity = $tmpActivities[0]; + } + + if (null === $tmpActivity) { + $tmpActivity = new Activity(); + $tmpActivity->setName($activity); + $tmpActivity->setComment($this->comment); + if ($activityType === 'project') { + $tmpActivity->setProject($project); + } + $this->activities->saveActivity($tmpActivity); + } + + return $tmpActivity; + } + + private function getProject($project, $customer, $fallbackCustomer): Project + { + /** @var Customer $tmpCustomer */ + $tmpCustomer = $this->getCustomer($customer, $fallbackCustomer); + /** @var Project $tmpProject */ + $tmpProject = null; + /** @var Project[] $tmpProjects */ + $tmpProjects = $this->projects->findBy(['name' => $project]); + + if (count($tmpProjects) > 1) { + /** @var Project $prj */ + foreach ($tmpProjects as $prj) { + if ($prj->getCustomer()->getName() !== $tmpCustomer->getName()) { + continue; + } + $tmpProject = $prj; + break; + } + } elseif (count($tmpProjects) === 1) { + $tmpProject = $tmpProjects[0]; + } + + if (null !== $tmpProject) { + if ($tmpProject->getCustomer()->getName() !== $tmpCustomer->getName()) { + $tmpProject = null; + } + } + + if ($tmpProject === null) { + $tmpProject = new Project(); + $tmpProject->setName($project); + $tmpProject->setComment($this->comment); + $tmpProject->setCustomer($tmpCustomer); + $this->projects->saveProject($tmpProject); + } + + return $tmpProject; + } + + private function getCustomer($customer, $fallback): Customer + { + if (!empty($customer)) { + if (!array_key_exists($customer, $this->customerCache)) { + $tmpCustomer = $this->customers->findBy(['name' => $customer]); + if (count($tmpCustomer) > 1) { + throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer)); + } elseif (count($tmpCustomer) === 1) { + $tmpCustomer = $tmpCustomer[0]; + } + + if ($tmpCustomer instanceof Customer) { + $this->customerCache[$customer] = $tmpCustomer; + } + } + + if (array_key_exists($customer, $this->customerCache)) { + return $this->customerCache[$customer]; + } + } + + if (null === $this->customerFallback) { + $tmpFallback = null; + + if (!empty($fallback)) { + if (is_int($customer)) { + $tmpFallback = $this->customers->find($fallback); + } else { + $tmpFallback = $this->customers->findOneBy(['name' => $fallback]); + } + } + + if (null === $tmpFallback) { + $newName = self::DEFAULT_CUSTOMER; + if (!empty($fallback) && is_string($fallback)) { + $newName = $fallback; + } + $tmpFallback = new Customer(); + $tmpFallback->setName(sprintf($newName, $this->dateTime)); + $tmpFallback->setComment($this->comment); + $tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry()); + $timezone = date_default_timezone_get(); + if (null !== $this->configuration->getCustomerDefaultTimezone()) { + $timezone = $this->configuration->getCustomerDefaultTimezone(); + } + $tmpFallback->setTimezone($timezone); + $this->customers->saveCustomer($tmpFallback); + } + + $this->customerFallback = $tmpFallback; + } + + return $this->customerFallback; + } + + /** + * @param array $row + * @return bool + * @throws InvalidFieldsException + */ + private function validateRow(array $row) + { + $fields = []; + + if (empty($row['Project'])) { + $fields[] = 'Project'; + } + + if (empty($row['Activity'])) { + $fields[] = 'Activity'; + } + + if (empty($row['Date'])) { + $fields[] = 'Date'; + } + + if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) { + $fields[] = 'Duration'; + } + + if (!empty($fields)) { + throw new InvalidFieldsException($fields); + } + + return true; + } + + private function validateHeader(array $header) + { + $result = array_diff(self::$supportedHeader, $header); + + return empty($result); + } +} diff --git a/src/Importer/InvalidFieldsException.php b/src/Importer/InvalidFieldsException.php new file mode 100644 index 00000000..edb36c5d --- /dev/null +++ b/src/Importer/InvalidFieldsException.php @@ -0,0 +1,28 @@ +fields = $fields; + } + + public function getFields(): array + { + return $this->fields; + } +} diff --git a/src/Importer/UnknownUserException.php b/src/Importer/UnknownUserException.php new file mode 100644 index 00000000..20c05fb5 --- /dev/null +++ b/src/Importer/UnknownUserException.php @@ -0,0 +1,31 @@ +username = $username; + } + + public function getUsername(): string + { + return $this->username; + } +} diff --git a/symfony.lock b/symfony.lock index 67d3e39f..154d9783 100644 --- a/symfony.lock +++ b/symfony.lock @@ -231,6 +231,9 @@ "laravolt/avatar": { "version": "3.0.0" }, + "league/csv": { + "version": "9.4.1" + }, "markbaker/complex": { "version": "1.4.6" }, diff --git a/tests/Importer/InvalidFieldsExceptionTest.php b/tests/Importer/InvalidFieldsExceptionTest.php new file mode 100644 index 00000000..5e321fe3 --- /dev/null +++ b/tests/Importer/InvalidFieldsExceptionTest.php @@ -0,0 +1,26 @@ +getFields()); + self::assertEquals('Missing fields: test, foo', $sut->getMessage()); + } +} diff --git a/tests/Importer/UnknownUserExceptionTest.php b/tests/Importer/UnknownUserExceptionTest.php new file mode 100644 index 00000000..c6c62886 --- /dev/null +++ b/tests/Importer/UnknownUserExceptionTest.php @@ -0,0 +1,26 @@ +getUsername()); + self::assertEquals('Unknown user: test', $sut->getMessage()); + } +}