From 50b5e93a10f429a9aa44c5fc0c97cc8f6f9352ac Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Tue, 23 Jan 2018 00:23:41 +0100 Subject: [PATCH] added command to import data from kimai v1 #102 (#103) --- README.md | 29 +- src/Command/KimaiImporterCommand.php | 834 +++++++++++++++++++++++++ src/Entity/Activity.php | 19 +- src/Entity/Customer.php | 6 +- src/Entity/Project.php | 1 + src/Entity/User.php | 2 +- src/EventSubscriber/MenuSubscriber.php | 2 +- 7 files changed, 869 insertions(+), 24 deletions(-) create mode 100644 src/Command/KimaiImporterCommand.php diff --git a/README.md b/README.md index 839c3f86..bf1d375b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It is based on a lot of great PHP components. Special thanks to: ## Requirements - PHP 7.1 or higher -- One PHP extension of PDO-SQLite and/or PDO-MySQL enabled +- One PHP extension of PDO-SQLite and/or PDO-MySQL enabled (it might work with PostgreSQL and Oracle as well, but that wasn't tested and is not officially supported) - and the [usual Symfony application requirements](http://symfony.com/doc/current/reference/requirements.html) If unsure about meeting these requirements, download the demo application and @@ -104,6 +104,33 @@ $ bin/console kimai:create-user username password admin@example.com ROLE_SUPER_A For available roles, please refer to [the user documentation](var/docs/users.md). +### Importing data from Kimai v1 + +Before importing your data from a Kimai v1 installation, please read the following carefully: + +- Data from the existing v1 installation is only read and will never be changed +- Data can only be imported from a Kimai installation with at least `v1.0.1` and database revision `1388` (check your `configuration` table) +- Kimai v1 has support for activities without project assignment, which Kimai v2 doesn't support. Unattached activities will be created for every project that has a linked activity in any of the imported timesheet records +- Rates and fixed-rates are handled in a completely different way and for now only the timesheet record total amounts are imported +- Customers cannot login and no user accounts will be created for them +- The customers country has to be manually assigned afterwards, as there is no field in Kimai v1 for that +- You have to supply the default password that is used for every imported user, as their password will be resetted +- Data that was deleted in Kimai v1 (user, customer, projects, activities) will be imported and set to `invisible` (if you don't want that, you have to delete all entries that have the value `1` in the `trash` column before importing) + +The full command for import: +```bash +$ bin/console kimai:import-v1 "mysql://user:password@127.0.0.1:3306/database?charset=utf8" "db_prefix" "password" "country" +``` + +It is recommended to test the import in a fresh database without any required data! Then you can test your import as often as you like and fix possible problems in your installation. +A sample command could look like that: +```bash +$ bin/console doctrine:schema:drop --force && bin/console doctrine:schema:create && bin/console kimai:import-v1 "mysql://kimai:test@127.0.0.1:3306/kimai?charset=latin1" "kimai_" "test123" "de" +``` +That will drop the configured Kimai v2 database schema and re-create it, before importing the data from the `mysql` database at `127.0.0.1` authenticating the user `kimai` with the password `test` for import. +The connection will use the charset `latin1` and the default table prefix `kimai_` for reading data. Imported users can login with the password `test123` and all customer will have the country `de` assigned. + + ## Usage There is no need to configure a virtual host in your web server to access the application for testing. diff --git a/src/Command/KimaiImporterCommand.php b/src/Command/KimaiImporterCommand.php new file mode 100644 index 00000000..92ce9baa --- /dev/null +++ b/src/Command/KimaiImporterCommand.php @@ -0,0 +1,834 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Command; + +use App\Doctrine\TimesheetSubscriber; +use App\Entity\Activity; +use App\Entity\Customer; +use App\Entity\Project; +use App\Entity\Timesheet; +use App\Entity\User; +use Doctrine\Common\Persistence\ObjectManager; +use Doctrine\DBAL\Configuration; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Symfony\Bridge\Doctrine\RegistryInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoder; +use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * Command used to import data from a Kimai v1 installation. + * Getting help in improving this script would be fantastic, it currently only handles the most basic use-cases. + * + * @author Kevin Papst + */ +class KimaiImporterCommand extends Command +{ + + // minimum required Kimai and database version, lower versions are not supported by this command + const MIN_VERSION = '1.0.1'; + const MIN_REVISION = '1388'; + + /** + * Create the user default passwords + * @var UserPasswordEncoder + */ + protected $encoder; + /** + * Validates the entities before they will be created + * @var ValidatorInterface + */ + protected $validator; + /** + * Connection to the Kimai v2 database to write imported data to + * @var RegistryInterface + */ + protected $doctrine; + /** + * Connection to the old database to import data from + * @var Connection + */ + protected $connection; + /** + * Prefix for the v1 database tables. + * @var string + */ + protected $dbPrefix = ''; + /** + * @var User[] + */ + protected $users = []; + /** + * @var Customer[] + */ + protected $customers = []; + /** + * @var Project[] + */ + protected $projects = []; + /** + * id => [projectId => Activity] + * @var Activity[] + */ + protected $activities = []; + /** + * activityId => activity[] + * @var array + */ + protected $unassignedActivities = []; + /** + * @var bool + */ + protected $debug = false; + + /** + * @param UserPasswordEncoderInterface $encoder + * @param RegistryInterface $registry + * @param ValidatorInterface $validator + */ + public function __construct( + UserPasswordEncoderInterface $encoder, + RegistryInterface $registry, + ValidatorInterface $validator + ) { + $this->encoder = $encoder; + $this->doctrine = $registry; + $this->validator = $validator; + + parent::__construct(); + } + + /** + * @inheritdoc + */ + protected function configure() + { + $this + ->setName('kimai:import-v1') + ->setDescription('Import data from a Kimai v1 installation') + ->setHelp('This command allows you to import the most important data from a Kimi v1 installation.') + ->addArgument('connection', InputArgument::REQUIRED, 'The database connection as URL, for example: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1') + ->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables') + ->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user') + ->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer', 'de') + ; + } + + /** + * @inheritdoc + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection()); + + $io = new SymfonyStyle($input, $output); + + $config = new Configuration(); + $connectionParams = ['url' => $input->getArgument('connection')]; + $this->connection = DriverManager::getConnection($connectionParams, $config); + + $this->dbPrefix = $input->getArgument('prefix'); + + $password = $input->getArgument('password'); + if (trim(strlen($password)) < 6) { + $io->error('Password length is not sufficient, at least 6 character are required'); + return; + } + + $country = $input->getArgument('country'); + if (trim(strlen($country)) != 2) { + $io->error('Country length needs to be exactly 2 character'); + return; + } + + if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) { + return; + } + + // pre-load all data to make sure we can fully import everything + $users = null; + $customer = null; + $projects = null; + $activities = null; + $records = null; + $activityToProject = null; + + $bytesStart = memory_get_usage(true); + + try { + $users = $this->fetchAllFromImport('users'); + } catch (\Exception $ex) { + $io->error('Failed to load users: ' . $ex->getMessage()); + return; + } + + try { + $customer = $this->fetchAllFromImport('customers'); + } catch (\Exception $ex) { + $io->error('Failed to load customers: ' . $ex->getMessage()); + return; + } + + try { + $projects = $this->fetchAllFromImport('projects'); + } catch (\Exception $ex) { + $io->error('Failed to load projects: ' . $ex->getMessage()); + return; + } + + try { + $activities = $this->fetchAllFromImport('activities'); + } catch (\Exception $ex) { + $io->error('Failed to load activities: ' . $ex->getMessage()); + return; + } + + try { + $activityToProject = $this->fetchAllFromImport('projects_activities'); + } catch (\Exception $ex) { + $io->error('Failed to load activities-project mapping: ' . $ex->getMessage()); + return; + } + + try { + $records = $this->fetchAllFromImport('timeSheet'); + } catch (\Exception $ex) { + $io->error('Failed to load timeSheet: ' . $ex->getMessage()); + return; + } + + $bytesCached = memory_get_usage(true); + + $io->success('Fetched Kimai v1 data, trying to import now ...'); + + $allImports = 0; + + try { + $counter = $this->importUsers($io, $password, $users); + $allImports += $counter; + $io->success('Imported users: ' . $counter); + } catch (\Exception $ex) { + $io->error('Failed to import users: ' . $ex->getMessage()); + return; + } + + try { + $counter = $this->importCustomers($io, $customer, $country); + $allImports += $counter; + $io->success('Imported customers: ' . $counter); + } catch (\Exception $ex) { + $io->error('Failed to import customers: ' . $ex->getMessage()); + return; + } + + try { + $counter = $this->importProjects($io, $projects); + $allImports += $counter; + $io->success('Imported projects: ' . $counter); + } catch (\Exception $ex) { + $io->error('Failed to import projects: ' . $ex->getMessage()); + return; + } + + try { + $counter = $this->importActivities($io, $activities, $activityToProject); + $allImports += $counter; + $io->success('Imported activities: ' . $counter); + } catch (\Exception $ex) { + $io->error('Failed to import activities: ' . $ex->getMessage()); + return; + } + + try { + $counter = $this->importTimesheetRecords($io, $records); + $allImports += $counter; + $io->success('Imported timesheet records: ' . $counter); + } catch (\Exception $ex) { + $io->error('Failed to import timesheet records: ' . $ex->getMessage()); + return; + } + + // TODO support fixedRates (projectID, activityID, rate) + // TODO support rates (userID, projectID, activityID, rate) + // TODO dump yaml config from configuration (adminmail, currency_name, date_format_0, language, roundPrecision) + // TODO support preferences (ui.lang, timezone) + // TODO support expenses - new database required + + $bytesImported = memory_get_usage(true); + + $io->success('Memory usage: ' . PHP_EOL . + 'Start: ' . $this->bytesHumanReadable($bytesStart) . PHP_EOL . + 'After caching: ' . $this->bytesHumanReadable($bytesCached) . PHP_EOL . + 'After import: ' . $this->bytesHumanReadable($bytesImported) . PHP_EOL . + 'Total consumption for importing '.$allImports.' new database entries: ' . $this->bytesHumanReadable($bytesImported - $bytesStart) + ); + } + + /** + * Checks if the ghiven database connection for import has an underlying database with a compatible structure. + * This is checked againstto the Kimai version and database revision. + * + * @param SymfonyStyle $io + * @param $requiredVersion + * @param $requiredRevision + * @return bool + * @throws \Doctrine\DBAL\DBALException + */ + protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision) + { + $version = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "version"')->fetchColumn(); + $revision = $this->getImportConnection()->query('SELECT value from ' . $this->dbPrefix . 'configuration WHERE option = "revision"')->fetchColumn(); + + if (version_compare($requiredVersion, $version) == 1) { + $io->error( + 'Import can only performed from an up-to-date Kimai version:' . PHP_EOL . + 'Needs at least ' . $requiredVersion . ' but found ' . $version + ); + return false; + } + + if (version_compare($requiredRevision, $revision) == 1) { + $io->error( + 'Import can only performed from an up-to-date Kimai version:' . PHP_EOL . + 'Database revision needs to be ' . $requiredRevision . ' but found ' . $revision + ); + return false; + } + + return true; + } + + /** + * Remove the timesheet lifecycle events subscriber, which would overwrite values for imported timesheet records. + * + * @param Connection $connection + */ + protected function deactivateLifecycleCallbacks(Connection $connection) + { + $allListener = $connection->getEventManager()->getListeners(); + foreach($allListener as $name => $listener) { + if (in_array($name, ['prePersist', 'preUpdate'])) { + foreach ($listener as $service => $class) { + if ($class === TimesheetSubscriber::class) { + $connection->getEventManager()->removeEventListener(['prePersist', 'preUpdate'], $class); + } + } + } + } + } + + /** + * Thanks to "xelozz -at- gmail.com", see http://php.net/manual/en/function.memory-get-usage.php#96280 + * @param $size + * @return string + */ + protected function bytesHumanReadable($size) + { + $unit=array('b','kB','MB','GB'); + return @round($size/pow(1024,($i=floor(log($size,1024)))),2).' '.$unit[$i]; + } + + /** + * @param $table + * @return array + * @throws \Doctrine\DBAL\DBALException + */ + protected function fetchAllFromImport($table) + { + return $this->getImportConnection()->query('SELECT * from ' . $this->dbPrefix . $table)->fetchAll(); + } + + /** + * @return \Doctrine\DBAL\Connection + */ + protected function getImportConnection() + { + return $this->connection; + } + + /** + * @return RegistryInterface + */ + protected function getDoctrine() + { + return $this->doctrine; + } + + /** + * @param SymfonyStyle $io + * @param $object + * @return bool + */ + protected function validateImport(SymfonyStyle $io, $object) + { + $errors = $this->validator->validate($object); + + if ($errors->count() > 0) { + /** @var \Symfony\Component\Validator\ConstraintViolation $error */ + foreach ($errors as $error) { + $value = $error->getInvalidValue(); + $io->error( + $error->getPropertyPath() + . " (" . (is_array($value) ? implode(',', $value) : $value) .")" + . "\n " + . $error->getMessage() + ); + } + return false; + } + + return true; + } + + /** + * -- are currently unsupported fields that can't be mapped + * + * ["userID"]=> string(9) "833336177" + * ["name"]=> string(5) "admin" + * ["alias"]=> NULL + * --- ["status"]=> string(1) "0" + * ["trash"]=> string(1) "0" + * ["active"]=> string(1) "1" + * ["mail"]=> string(21) "foo@bar.com" + * ["password"]=> string(32) "" + * ["passwordResetHash"]=> NULL + * ["ban"]=> string(1) "0" + * ["banTime"]=> string(1) "0" + * --- ["secure"]=> string(30) "" + * ["lastProject"]=> string(1) "2" + * ["lastActivity"]=> string(1) "2" + * ["lastRecord"]=> string(1) "2" + * ["timeframeBegin"]=> string(10) "1304200800" + * ["timeframeEnd"]=> string(1) "0" + * ["apikey"]=> NULL + * ["globalRoleID"]=> string(1) "1" + * + * @param SymfonyStyle $io + * @param string $password + * @param array $users + * @return int + * @throws \Exception + */ + protected function importUsers(SymfonyStyle $io, $password, $users) + { + $counter = 0; + $entityManager = $this->getDoctrine()->getManager(); + + foreach ($users as $oldUser) + { + $isActive = (bool)$oldUser['active'] && !(bool)$oldUser['trash'] && !(bool)$oldUser['ban']; + $role = ($oldUser['globalRoleID'] == 1) ? User::ROLE_SUPER_ADMIN : User::DEFAULT_ROLE; + + $user = new User(); + $user->setUsername($oldUser['name']) + ->setAlias($oldUser['alias']) + ->setEmail($oldUser['mail']) + ->setPlainPassword($password) + ->setActive($isActive) + ->setRoles([$role]) + ; + + $pwd = $this->encoder->encodePassword($user, $user->getPlainPassword()); + $user->setPassword($pwd); + + if (!$this->validateImport($io, $user)) { + throw new \Exception('Failed to validate user: ' . $user->getUsername()); + } + + try { + $entityManager->persist($user); + $entityManager->flush(); + if ($this->debug) { + $io->success('Created user: ' . $user->getUsername()); + } + $counter++; + } catch (\Exception $ex) { + $io->error('Failed to create user: ' . $user->getUsername()); + $io->error('Reason: ' . $ex->getMessage()); + } + + $this->users[$oldUser['userID']] = $user; + } + return $counter; + } + + /** + * -- are currently unsupported fields that can't be mapped + * + * ["customerID"]=> string(2) "11" + * ["name"]=> string(9) "Customer" + * ["password"]=> NULL + * ["passwordResetHash"]=> NULL + * ["secure"]=> NULL + * ["comment"]=> NULL + * ["visible"]=> string(1) "1" + * ["filter"]=> string(1) "0" + * ["company"]=> string(14) "Customer Ltd." + * --- ["vat"]=> string(2) "19" + * ["contact"]=> string(2) "Someone" + * ["street"]=> string(22) "Street name" + * ["zipcode"]=> string(5) "12345" + * ["city"]=> string(6) "Berlin" + * ["phone"]=> NULL + * ["fax"]=> NULL + * ["mobile"]=> NULL + * --- ["mail"]=> NULL + * --- ["homepage"]=> NULL + * ["trash"]=> string(1) "0" + * ["timezone"]=> string(13) "Europe/Berlin" + * + * @param SymfonyStyle $io + * @param array $customers + * @param string $country + * @return int + * @throws \Exception + */ + protected function importCustomers(SymfonyStyle $io, $customers, $country) + { + $counter = 0; + $entityManager = $this->getDoctrine()->getManager(); + + foreach ($customers as $oldCustomer) + { + $isActive = (bool)$oldCustomer['visible'] && !(bool)$oldCustomer['trash']; + $name = $oldCustomer['name']; + if (empty($name)) { + $name = uniqid(); + $io->warning('Found empty customer name, setting it to: ' . $name); + } + + $customer = new Customer(); + $customer + ->setName($name) + ->setComment($oldCustomer['comment']) + ->setCompany($oldCustomer['company']) + ->setFax($oldCustomer['fax']) + ->setMobile($oldCustomer['mobile']) + ->setPhone($oldCustomer['phone']) + ->setContact($oldCustomer['contact']) + ->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city']) + ->setTimezone($oldCustomer['timezone']) + ->setVisible($isActive) + ->setCountry($country) + ; + + if (!$this->validateImport($io, $customer)) { + throw new \Exception('Failed to validate customer: ' . $customer->getName()); + } + + try { + $entityManager->persist($customer); + $entityManager->flush(); + if ($this->debug) { + $io->success('Created customer: ' . $customer->getName()); + } + $counter++; + } catch (\Exception $ex) { + $io->error('Reason: ' . $ex->getMessage()); + $io->error('Failed to create customer: ' . $customer->getName()); + } + + $this->customers[$oldCustomer['customerID']] = $customer; + } + return $counter; + } + + /** + * -- are currently unsupported fields that can't be mapped + * + * ["projectID"]=> string(1) "1" + * ["customerID"]=> string(1) "1" + * ["name"]=> string(11) "Test" + * ["comment"]=> string(0) "" + * ["visible"]=> string(1) "1" + * --- ["filter"]=> string(1) "0" + * ["trash"]=> string(1) "1" + * --- ["budget"]=> string(4) "0.00" + * --- ["effort"]=> NULL + * --- ["approved"]=> NULL + * --- ["internal"]=> string(1) "0" + * + * @param SymfonyStyle $io + * @param array $projects + * @return int + * @throws \Exception + */ + protected function importProjects(SymfonyStyle $io, $projects) + { + $counter = 0; + $entityManager = $this->getDoctrine()->getManager(); + + foreach ($projects as $oldProject) + { + $isActive = (bool)$oldProject['visible'] && !(bool)$oldProject['trash']; + $customer = $this->customers[$oldProject['customerID']]; + $name = $oldProject['name']; + if (empty($name)) { + $name = uniqid(); + $io->warning('Found empty project name, setting it to: ' . $name); + } + + $project = new Project(); + $project + ->setCustomer($customer) + ->setName($name) + ->setComment($oldProject['comment'] ?: null) + ->setVisible($isActive) + ; + + if (!$this->validateImport($io, $project)) { + throw new \Exception('Failed to validate project: ' . $project->getName()); + } + + try { + $entityManager->persist($project); + $entityManager->flush(); + if ($this->debug) { + $io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName()); + } + $counter++; + } catch (\Exception $ex) { + $io->error('Failed to create project: ' . $project->getName()); + $io->error('Reason: ' . $ex->getMessage()); + } + + $this->projects[$oldProject['projectID']] = $project; + } + return $counter; + } + + /** + * -- are currently unsupported fields that can't be mapped + * + * $activities: + * -- ["activityID"]=> string(1) "1" + * ["name"]=> string(6) "Test" + * ["comment"]=> string(0) "" + * ["visible"]=> string(1) "1" + * --- ["filter"]=> string(1) "0" + * ["trash"]=> string(1) "1" + * + * $activityToProject + * ["projectID"]=> string(1) "1" + * ["activityID"]=> string(1) "1" + * -- ["budget"]=> string(4) "0.00" + * -- ["effort"]=> string(4) "0.00" + * -- ["approved"]=> string(4) "0.00" + * + * @param SymfonyStyle $io + * @param array $activities + * @param array $activityToProject + * @return int + * @throws \Exception + */ + protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject) + { + $counter = 0; + $entityManager = $this->getDoctrine()->getManager(); + $oldActivityMapping = []; + foreach ($activityToProject as $mapping) + { + $oldActivityMapping[$mapping['activityID']] = $mapping['projectID']; + } + + foreach ($activities as $oldActivity) + { + if (isset($oldActivityMapping[$oldActivity['activityID']])) + { + $projectId = $oldActivityMapping[$oldActivity['activityID']]; + $project = null; + + if (!isset($this->projects[$projectId])) { + throw new \Exception('Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId); + } + + $project = $this->projects[$projectId]; + + $this->unassignedActivities[$oldActivity['activityID']] = $oldActivity; + $this->createActivity($io, $entityManager, $project, $oldActivity); + $counter++; + } else { + $this->unassignedActivities[$oldActivity['activityID']] = $oldActivity; + } + } + return $counter; + } + + /** + * @param SymfonyStyle $io + * @param ObjectManager $entityManager + * @param Project $project + * @param array $oldActivity + * @return Activity + * @throws \Exception + */ + protected function createActivity(SymfonyStyle $io, ObjectManager $entityManager, Project $project, array $oldActivity) + { + $activityId = $oldActivity['activityID']; + if (isset($this->activities[$activityId][$project->getId()])) { + return $this->activities[$activityId][$project->getId()]; + } + + $isActive = (bool)$oldActivity['visible'] && !(bool)$oldActivity['trash']; + $name = $oldActivity['name']; + if (empty($name)) { + $name = uniqid(); + $io->warning('Found empty activity name, setting it to: ' . $name); + } + + $activity = new Activity(); + $activity + ->setName($name) + ->setComment($oldActivity['comment'] ?: null) + ->setVisible($isActive) + ->setProject($project) + ; + + if (!$this->validateImport($io, $activity)) { + throw new \Exception('Failed to validate activity: ' . $activity->getName()); + } + + try { + $entityManager->persist($activity); + $entityManager->flush(); + if ($this->debug) { + $io->success('Created activity: ' . $activity->getName()); + } + } catch (\Exception $ex) { + $io->error('Failed to create activity: ' . $activity->getName()); + $io->error('Reason: ' . $ex->getMessage()); + } + + if (!isset($this->activities[$activityId])) { + $this->activities[$activityId] = []; + } + $this->activities[$activityId][$project->getId()] = $activity; + + return $activity; + } + + /** + * -- are currently unsupported fields that can't be mapped + * + * -- ["timeEntryID"]=> string(1) "1" + * ["start"]=> string(10) "1306747800" + * ["end"]=> string(10) "1306752300" + * ["duration"]=> string(4) "4500" + * ["userID"]=> string(9) "228899434" + * ["projectID"]=> string(1) "1" + * ["activityID"]=> string(1) "1" + * ["description"]=> NULL + * ["comment"]=> string(36) "a work description" + * -- ["commentType"]=> string(1) "0" + * -- ["cleared"]=> string(1) "0" + * -- ["location"]=> string(0) "" + * -- ["trackingNumber"]=> NULL + * ["rate"]=> string(5) "50.00" + * ["fixedRate"]=> string(4) "0.00" + * -- ["budget"]=> NULL + * -- ["approved"]=> NULL + * -- ["statusID"]=> string(1) "1" + * -- ["billable"]=> NULL + * + * @param SymfonyStyle $io + * @param array $records + * @return int + * @throws \Exception + */ + protected function importTimesheetRecords(SymfonyStyle $io, array $records) + { + $counter = 0; + $activityCounter = 0; + $entityManager = $this->getDoctrine()->getManager(); + + foreach ($records as $oldRecord) + { + $activity = null; + $project = null; + $activityId = $oldRecord['activityID']; + $projectId = $oldRecord['projectID']; + + if (isset($this->projects[$projectId])) { + $project = $this->projects[$projectId]; + } else { + $io->error('Could not create timesheet record, missing project with ID: ' . $projectId); + continue; + } + + if (isset($this->activities[$activityId][$projectId])) { + $activity = $this->activities[$activityId][$projectId]; + } + + if ($activity === null && isset($this->unassignedActivities[$activityId])) { + $oldActivity = $this->unassignedActivities[$activityId]; + $activity = $this->createActivity($io, $entityManager, $project, $oldActivity); + $activityCounter++; + } + + if ($activity === null) { + $io->error('Could not create timesheet record, missing activity with ID: ' . $activityId); + continue; + } + + $duration = $oldRecord['end'] - $oldRecord['start']; + + $rate = $oldRecord['fixedRate']; + if ((empty($rate) || $rate == 0.00) && !empty($oldRecord['rate'])) { + $hourlyRate = (float) $oldRecord['rate']; + $rate = (float) $hourlyRate * ($duration / 3600); + $rate = round($rate, 2); + } + + $timesheet = new Timesheet(); + $timesheet + ->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null)) + ->setUser($this->users[$oldRecord['userID']]) + ->setBegin(new \DateTime("@".$oldRecord['start'])) + ->setEnd(new \DateTime("@".$oldRecord['end'])) + ->setDuration($duration) + ->setActivity($activity) + ->setRate($rate) + ; + + if (!$this->validateImport($io, $timesheet)) { + throw new \Exception('Failed to validate timesheet record: ' . $timesheet->getId()); + } + + try { + $entityManager->persist($timesheet); + $entityManager->flush(); + if ($this->debug) { + $io->success('Created timesheet record: ' . $timesheet->getId()); + } + $counter++; + } catch (\Exception $ex) { + $io->error('Failed to create timesheet record: ' . $timesheet->getId()); + $io->error('Reason: ' . $ex->getMessage()); + } + + if ($counter % 500 == 0) { + $io->writeln('Imported ' . $counter . ' timesheet records, import ongoing ...'); + } + } + + if ($activityCounter > 0) { + $io->success('Created new (previously unattached) activities during timesheet import: ' . $activityCounter); + } + return $counter; + } +} diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index fe3876f4..1ac22b44 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -48,6 +48,7 @@ class Activity * * @ORM\Column(name="name", type="string", length=255, nullable=false) * @Assert\NotBlank() + * @Assert\Length(min=2, max=255) */ private $name; @@ -73,24 +74,6 @@ class Activity */ private $timesheets; - /** - * @return Timesheet[] - */ - public function getTimesheets() - { - return $this->timesheets; - } - - /** - * @param Timesheet[] $timesheets - * @return Activity - */ - public function setTimesheets($timesheets) - { - $this->timesheets = $timesheets; - return $this; - } - /** * @return Project */ diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 3a7308ad..64e60317 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -41,14 +41,14 @@ class Customer * * @ORM\Column(name="name", type="string", length=255, nullable=false) * @Assert\NotBlank() + * @Assert\Length(min=2, max=255) */ private $name; /** * @var string * - * @ORM\Column(name="number", type="string", length=50, nullable=false) - * @Assert\NotBlank() + * @ORM\Column(name="number", type="string", length=50, nullable=true) */ private $number; @@ -98,7 +98,7 @@ class Customer /** * @var string * - * @ORM\Column(name="country", type="string", length=2, nullable=true) + * @ORM\Column(name="country", type="string", length=2, nullable=false) * @Assert\NotBlank() */ private $country; diff --git a/src/Entity/Project.php b/src/Entity/Project.php index a87bc509..8d237161 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -48,6 +48,7 @@ class Project * * @ORM\Column(name="name", type="string", length=255, nullable=false) * @Assert\NotNull() + * @Assert\Length(min=2, max=255) */ private $name; diff --git a/src/Entity/User.php b/src/Entity/User.php index 86abd730..205fd9dc 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -51,7 +51,7 @@ class User implements UserInterface, AdvancedUserInterface * * @ORM\Column(name="name", type="string", length=60, nullable=false, unique=true) * @Assert\NotBlank() - * @Assert\Length(min=6, max=160) + * @Assert\Length(min=5, max=60) */ private $username; diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index af15862e..6521dd73 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -59,7 +59,7 @@ class MenuSubscriber implements EventSubscriberInterface $isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED'); $isUser = $isLoggedIn && $auth->isGranted('ROLE_USER'); - $isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_USER'); + $isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_TEAMLEAD'); if (!$isLoggedIn || !$isUser) { return;