diff --git a/.gitignore b/.gitignore index 335702366..cf16c06df 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ js/ .code-workspace .DS_Store .idea/ +.phpunit.result.cache .vscode/ .vscode-upload.json .*.sw* diff --git a/appinfo/routes.php b/appinfo/routes.php index 51e4631eb..14a736fd3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -126,9 +126,6 @@ ['name' => 'page#getPublicFileShare', 'url' => '/getPublicFileShare', 'verb' => 'POST'], ['name' => 'page#importCsvProject', 'url' => '/import-csv-project', 'verb' => 'GET'], ['name' => 'page#importSWProject', 'url' => '/import-sw-project', 'verb' => 'GET'], - ['name' => 'page#exportCsvProject', 'url' => '/export-csv-project/{projectid}', 'verb' => 'GET'], - ['name' => 'page#exportCsvStatistics', 'url' => '/export-csv-statistics/{projectid}', 'verb' => 'GET'], - ['name' => 'page#exportCsvSettlement', 'url' => '/export-csv-settlement/{projectid}', 'verb' => 'GET'], ['name' => 'page#webGetProjects', 'url' => '/projects', 'verb' => 'GET'], ['name' => 'page#webGetProjects2', 'url' => '/getProjects', 'verb' => 'POST'], ['name' => 'page#webCreateProject', 'url' => '/projects', 'verb' => 'POST'], @@ -154,5 +151,9 @@ ['name' => 'page#pubLogin', 'url' => 'login', 'verb' => 'GET'], ['name' => 'page#pubProject', 'url' => 'project', 'verb' => 'POST'], ['name' => 'page#publicShareLinkPage', 'url' => 's/{token}', 'verb' => 'GET'], + + ['name' => 'export#exportCsvProject', 'url' => '/export-csv-project/{projectId}', 'verb' => 'GET'], + ['name' => 'export#exportCsvSettlement', 'url' => '/export-csv-settlement/{projectId}', 'verb' => 'GET'], + ['name' => 'export#exportCsvStatistics', 'url' => '/export-csv-statistics/{projectId}', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/ExportController.php b/lib/Controller/ExportController.php new file mode 100644 index 000000000..ab2a4dab8 --- /dev/null +++ b/lib/Controller/ExportController.php @@ -0,0 +1,130 @@ +translation = $translation; + $this->projectService = $projectService; + $this->exportService = $exportService; + $this->userId = $userId; + } + + /** + * @NoAdminRequired + */ + public function exportCsvProject(string $projectId, ?string $name = null, ?string $uid = null): DataResponse { + $userId = $uid; + if ($this->userId) { + $userId = $this->userId; + } + + if ($this->projectService->userCanAccessProject($userId, $projectId)) { + $result = $this->exportService->exportCsvProject($projectId, $userId, $name); + if (isset($result['path'])) { + return new DataResponse($result); + } else { + return new DataResponse($result, 400); + } + } else { + return new DataResponse( + ['message' => $this->translation->t('You are not allowed to export this project')], + 403 + ); + } + } + + /** + * @NoAdminRequired + */ + public function exportCsvSettlement(string $projectId, ?int $centeredOn = null, ?int $maxTimestamp = null): DataResponse { + if ($this->projectService->userCanAccessProject($this->userId, $projectId)) { + $result = $this->exportService->exportCsvSettlement($projectId, $this->userId, $centeredOn, $maxTimestamp); + if (isset($result['path'])) { + return new DataResponse($result); + } else { + return new DataResponse($result, 400); + } + } else { + return new DataResponse( + ['message' => $this->translation->t('You are not allowed to export this project settlement')], + 403 + ); + } + } + + /** + * @NoAdminRequired + */ + public function exportCsvStatistics(string $projectId, ?int $tsMin = null, ?int $tsMax = null, + ?int $paymentModeId = null, ?int $category = null, + ?float $amountMin = null, ?float $amountMax = null, int $showDisabled = 1, + ?int $currencyId = null): DataResponse { + if ($this->projectService->userCanAccessProject($this->userId, $projectId)) { + $result = $this->exportService->exportCsvStatistics( + $projectId, $this->userId, $tsMin, $tsMax, + $paymentModeId, $category, $amountMin, $amountMax, + $showDisabled !== 0, $currencyId + ); + if (isset($result['path'])) { + return new DataResponse($result); + } else { + return new DataResponse($result, 400); + } + } else { + return new DataResponse( + ['message' => $this->translation->t('You are not allowed to export this project statistics')], + 403 + ); + } + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 7a94da2e9..5a640c605 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -3143,75 +3143,6 @@ public function getPublicFileShare(string $path): DataResponse { return $response; } - /** - * @NoAdminRequired - */ - public function exportCsvSettlement(string $projectid, ?int $centeredOn = null, ?int $maxTimestamp = null): DataResponse { - if ($this->projectService->userCanAccessProject($this->userId, $projectid)) { - $result = $this->projectService->exportCsvSettlement($projectid, $this->userId, $centeredOn, $maxTimestamp); - if (isset($result['path'])) { - return new DataResponse($result); - } else { - return new DataResponse($result, 400); - } - } else { - return new DataResponse( - ['message' => $this->trans->t('You are not allowed to export this project settlement')], - 403 - ); - } - } - - /** - * @NoAdminRequired - */ - public function exportCsvStatistics(string $projectid, ?int $tsMin = null, ?int $tsMax = null, - ?int $paymentModeId = null, ?int $category = null, - ?float $amountMin = null, ?float $amountMax = null, int $showDisabled = 1, - ?int $currencyId = null): DataResponse { - if ($this->projectService->userCanAccessProject($this->userId, $projectid)) { - $result = $this->projectService->exportCsvStatistics( - $projectid, $this->userId, $tsMin, $tsMax, - $paymentModeId, $category, $amountMin, $amountMax, - $showDisabled !== 0, $currencyId - ); - if (isset($result['path'])) { - return new DataResponse($result); - } else { - return new DataResponse($result, 400); - } - } else { - return new DataResponse( - ['message' => $this->trans->t('You are not allowed to export this project statistics')], - 403 - ); - } - } - - /** - * @NoAdminRequired - */ - public function exportCsvProject(string $projectid, ?string $name = null, ?string $uid = null): DataResponse { - $userId = $uid; - if ($this->userId) { - $userId = $this->userId; - } - - if ($this->projectService->userCanAccessProject($userId, $projectid)) { - $result = $this->projectService->exportCsvProject($projectid, $userId, $name); - if (isset($result['path'])) { - return new DataResponse($result); - } else { - return new DataResponse($result, 400); - } - } else { - return new DataResponse( - ['message' => $this->trans->t('You are not allowed to export this project')], - 403 - ); - } - } - /** * @NoAdminRequired */ diff --git a/lib/Cron/AutoExport.php b/lib/Cron/AutoExport.php index c0e3acbc1..0b1aa9867 100644 --- a/lib/Cron/AutoExport.php +++ b/lib/Cron/AutoExport.php @@ -9,26 +9,25 @@ namespace OCA\Cospend\Cron; -use OCA\Cospend\Service\ProjectService; +use OCA\Cospend\Service\ExportService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; class AutoExport extends TimedJob { /** - * @var ProjectService + * @var ExportService */ - private $projectService; + private $exportService; /** * @param ITimeFactory $time - * @param ProjectService $projectService + * @param ExportService $exportService */ - public function __construct(ITimeFactory $time, ProjectService $projectService) { + public function __construct(ITimeFactory $time, ExportService $exportService) { parent::__construct($time); - $this->projectService = $projectService; - // Run each day - $this->setInterval(24 * 60 * 60); + $this->exportService = $exportService; + $this->setInterval(24 * 60 * 60); // Run each day } /** @@ -36,6 +35,6 @@ public function __construct(ITimeFactory $time, ProjectService $projectService) * @return void */ protected function run($argument): void { - $this->projectService->cronAutoExport(); + $this->exportService->cronAutoExport(); } } diff --git a/lib/Exception/ErrorMessageException.php b/lib/Exception/ErrorMessageException.php new file mode 100644 index 000000000..f4afce8d8 --- /dev/null +++ b/lib/Exception/ErrorMessageException.php @@ -0,0 +1,15 @@ +translation = $translation; + $this->config = $config; + $this->userManager = $userManager; + $this->rootFolder = $rootFolder; + $this->logger = $logger; + } +} diff --git a/lib/Service/ExportService.php b/lib/Service/ExportService.php new file mode 100644 index 000000000..3ce86887a --- /dev/null +++ b/lib/Service/ExportService.php @@ -0,0 +1,551 @@ +dbConnection = $dbConnection; + $this->projectService = $projectService; + } + + /** + * auto export + * triggered by NC cron job + * + * export projects + * + * @return void + */ + public function cronAutoExport(): void { + $queryBuilder = $this->dbConnection->getQueryBuilder(); + + foreach ($this->userManager->search('') as $user) { + $userId = $user->getUID(); + $outputPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); + + $queryBuilder->select('id', 'autoexport') + ->from('cospend_projects') + ->where( + $queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $queryBuilder->expr()->neq('autoexport', $queryBuilder->createNamedParameter(Application::FREQUENCIES['no'], IQueryBuilder::PARAM_STR)) + ); + + try { + $request = $queryBuilder->executeQuery(); + } catch (Exception $exception) { + $this->logger->error($exception->getMessage(), ['exception' => $exception]); + + continue; + } + + while ($row = $request->fetch()) { + try { + $exportName = $row['id'] . $this->getAutoExportSuffix($row['autoexport']) . '.csv'; + $userFolder = $this->rootFolder->getUserFolder($userId); + + // check if file already exists + if (!$userFolder->nodeExists($outputPath . '/' . $exportName)) { + $this->exportCsvProject($row['id'], $userId, $exportName); + } + } catch (NotPermittedException|NoUserException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + } + } + + $request->closeCursor(); + $queryBuilder = $queryBuilder->resetQueryParts(); + } + } + + /** + * Export project in CSV + * + * @param string $projectId + * @param string|null $name + * @param string $userId + * @return array + */ + public function exportCsvProject(string $projectId, string $userId, ?string $name = null): array { + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + $outputPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); + + if (!is_null($name)) { + $filename = $name; + if (!Utils::endswith($filename, '.csv')) { + $filename .= '.csv'; + } + } else { + $filename = $projectId . '.csv'; + } + + try { + $file = $this->createExportFile($userFolder, $outputPath, $filename); + } catch (ErrorMessageException $exception) { + return ['message' => $exception->getMessage()]; + } + + $projectInfo = $this->projectService->getProjectInfo($projectId); + $bills = $this->projectService->getBills($projectId); + $handler = $file->fopen('w'); + } catch (Exception|LockedException|NotPermittedException|NoUserException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + + return ['message' => $this->translation->t('Access denied')]; + } + + list($memberIdToName, $memberIdToWeight, $memberIdToActive) = $this->writeMembers($handler, $projectInfo['members']); + $this->writeBills($handler, $bills, $memberIdToName, $memberIdToWeight, $memberIdToActive); + $this->writeCategories($handler, $projectInfo['categories']); + $this->writePaymentModes($handler, $projectInfo['paymentmodes']); + $this->writeCurrencies($handler, $projectInfo['currencies'], $projectInfo['currencyname']); + + fclose($handler); + try { + $file->touch(); + } catch (InvalidPathException|NotFoundException|NotPermittedException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + // ignore exception as the file is already written + } + + return ['path' => $outputPath . '/' . $filename]; + } + + /** + * Export settlement plan in CSV. + * + * @param string $projectId + * @param string $userId + * @param int|null $centeredOn + * @param int|null $maxTimestamp + * @return array + */ + public function exportCsvSettlement(string $projectId, string $userId, ?int $centeredOn = null, ?int $maxTimestamp = null): array { + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + $outputPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); + $filename = $projectId . '-settlement.csv'; + + try { + $file = $this->createExportFile($userFolder, $outputPath, $filename); + } catch (ErrorMessageException $exception) { + return ['message' => $exception->getMessage()]; + } + + $settlement = $this->projectService->getProjectSettlement($projectId, $centeredOn, $maxTimestamp); + $members = $this->projectService->getMembers($projectId); + $handler = $file->fopen('w'); + } catch (LockedException|NotPermittedException|NoUserException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + + return ['message' => $this->translation->t('Access denied')]; + } + + $memberIdToName = []; + foreach ($members as $member) { + $memberIdToName[$member['id']] = $member['name']; + } + + fwrite( + $handler, + '"' . $this->translation->t('Who pays?') . '",' . + '"' . $this->translation->t('To whom?') . '",' . + '"' . $this->translation->t('How much?') . '"' . + "\n" + ); + + foreach ($settlement['transactions'] as $transaction) { + fwrite( + $handler, + '"' . $memberIdToName[$transaction['from']] . '",' . + '"' . $memberIdToName[$transaction['to']] . '",' . + (float)$transaction['amount'] . + "\n" + ); + } + + fclose($handler); + try { + $file->touch(); + } catch (InvalidPathException|NotFoundException|NotPermittedException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + // ignore exception as the file is already written + } + + return ['path' => $outputPath . '/' . $filename]; + } + + /** + * @param string $projectId + * @param string $userId + * @param int|null $tsMin + * @param int|null $tsMax + * @param int|null $paymentModeId + * @param int|null $category + * @param float|null $amountMin + * @param float|null $amountMax + * @param bool $showDisabled + * @param int|null $currencyId + * @return array + */ + public function exportCsvStatistics(string $projectId, string $userId, ?int $tsMin = null, ?int $tsMax = null, + ?int $paymentModeId = null, ?int $category = null, + ?float $amountMin = null, ?float $amountMax = null, + bool $showDisabled = true, ?int $currencyId = null): array { + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + $outputPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); + $filename = $projectId . '-stats.csv'; + + try { + $file = $this->createExportFile($userFolder, $outputPath, $filename); + } catch (ErrorMessageException $exception) { + return ['message' => $exception->getMessage()]; + } + + $allStats = $this->projectService->getProjectStatistics( + $projectId, 'lowername', $tsMin, $tsMax, $paymentModeId, + $category, $amountMin, $amountMax, $showDisabled, $currencyId + ); + $handler = $file->fopen('w'); + } catch (Exception|LockedException|NotPermittedException|NoUserException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + + return ['message' => $this->translation->t('Access denied')]; + } + + fwrite( + $handler, + '"' . $this->translation->t('Member name') . '",' . + '"' . $this->translation->t('Paid') . '",' . + '"' . $this->translation->t('Spent') . '",' . + '"' . $this->translation->t('Balance') . '"' . + "\n" + ); + + foreach ($allStats['stats'] as $stat) { + fwrite( + $handler, + '"' . $stat['member']['name'] . '",' . + (float)$stat['paid'] . ',' . + (float)$stat['spent'] . ',' . + (float)$stat['balance'] . + "\n" + ); + } + + fclose($handler); + try { + $file->touch(); + } catch (InvalidPathException|NotFoundException|NotPermittedException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + // ignore exception as the file is already written + } + + return ['path' => $outputPath . '/' . $filename]; + } + + /** + * @param string $autoExport + * @return string + */ + protected function getAutoExportSuffix(string $autoExport): string { + date_default_timezone_set('UTC'); + + switch ($autoExport) { + case Application::FREQUENCIES['daily']: + return '_' . $this->translation->t('daily') . '_' . (new DateTime('yesterday'))->format('Y-m-d'); + + case Application::FREQUENCIES['weekly']: + return '_' . $this->translation->t('weekly') . '_' . (new DateTime('sunday last week'))->format('Y-m-d'); + + case Application::FREQUENCIES['bi_weekly']: + $exportDate = new DateTime('sunday last week'); + if ($exportDate->format('W') % 2 === 1) { + $exportDate->modify('-1 week'); + } + + return '_' . $this->translation->t('bi_weekly') . '_' . $exportDate->format('Y-m-d'); + + case Application::FREQUENCIES['semi_monthly']: + $currentDay = (int)(new DateTime())->format('d'); + if ($currentDay === 1 || $currentDay > 15) { + return '_' . $this->translation->t('semi_monthly') . '_' . DateTime::createFromFormat('d', 15)->format('Y-m-d'); + } else { + return '_' . $this->translation->t('semi_monthly') . '_' . DateTime::createFromFormat('d', 1)->format('Y-m-d'); + } + + case Application::FREQUENCIES['monthly']: + return '_' . $this->translation->t('monthly') . '_' . (new DateTime('last month'))->format('Y-m'); + + case Application::FREQUENCIES['yearly']: + return '_' . $this->translation->t('yearly') . '_' . (new DateTime('last year'))->format('Y'); + + default: + return '_unknown_frequency' . (new DateTime())->format('Y-m-d'); + } + } + + /** + * Create and return the export file. If an error occurs, a error message string is returned. + * + * @param Folder $userFolder + * @param string $outPath + * @param string $filename + * @return File + * @throws ErrorMessageException + */ + protected function createExportFile(Folder $userFolder, string $outPath, string $filename): File { + $folder = $this->getExportDirectory($userFolder, $outPath); + + try { + if ($folder->nodeExists($filename)) { + $folder->get($filename)->delete(); + } + + return $folder->newFile($filename); + } catch (InvalidPathException|NotFoundException|NotPermittedException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + + throw new ErrorMessageException($this->translation->t('Impossible to create %1$s', [$filename])); + } + } + + /** + * Return the directory where things will be exported. If the directory does not exist, it will be created. + * If an error occurs, an error message string is returned. + * + * @param Folder $userFolder + * @param string $outputPath + * @return Folder + * @throws ErrorMessageException + */ + protected function getExportDirectory(Folder $userFolder, string $outputPath): Folder { + try { + if ($userFolder->nodeExists($outputPath)) { + $folder = $userFolder->get($outputPath); + } else { + $folder = $userFolder->newFolder($outputPath); + } + } catch (NotFoundException|NotPermittedException $exception) { + $this->logger->debug($exception->getMessage(), ['exception' => $exception]); + + throw new ErrorMessageException($this->translation->t('Impossible to create %1$s', [$outputPath])); + } + + if ($folder->getType() !== FileInfo::TYPE_FOLDER) { + throw new ErrorMessageException($this->translation->t('%1$s is not a folder', [$outputPath])); + } elseif (!$folder->isCreatable()) { + throw new ErrorMessageException($this->translation->t('%1$s is not writeable', [$outputPath])); + } else { + return $folder; + } + } + + /** + * @param $handler + * @param array $members + * @return array[] + */ + protected function writeMembers($handler, array $members): array { + $memberIdToName = []; + $memberIdToWeight = []; + $memberIdToActive = []; + + fwrite( + $handler, + 'name,weight,active,color' . "\n" + ); + + foreach ($members as $member) { + $memberIdToName[$member['id']] = $member['name']; + $memberIdToWeight[$member['id']] = $member['weight']; + $memberIdToActive[$member['id']] = (int)$member['activated']; + + fwrite( + $handler, + '"' . $member['name'] . '",' . + (float)$member['weight'] . ',' . + (int)$member['activated'] . ',' . + '"' . sprintf("#%02x%02x%02x", $member['color']['r'] ?? 0, $member['color']['g'] ?? 0, $member['color']['b'] ?? 0) . '"' . + "\n" + ); + } + + return [$memberIdToName, $memberIdToWeight, $memberIdToActive]; + } + + /** + * @param $handler + * @param array $bills + * @param array $memberIdToName + * @param array $memberIdToWeight + * @param array $memberIdToActive + * @return void + */ + protected function writeBills($handler, array $bills, array $memberIdToName, array $memberIdToWeight, array $memberIdToActive): void { + fwrite($handler, "\n"); + fwrite( + $handler, + 'what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,repeatfreq,repeatallactive,repeatuntil,categoryid,paymentmode,paymentmodeid,comment' . "\n" + ); + + foreach ($bills as $bill) { + $owerNames = []; + foreach ($bill['owers'] as $ower) { + $owerNames[] = $ower['name']; + } + + $payerId = $bill['payer_id']; + fwrite( + $handler, + '"' . $bill['what'] . '",' . + (float)$bill['amount'] . ',' . + DateTime::createFromFormat('U', $bill['timestamp'])->format('Y-m-d') . ',' . + $bill['timestamp'] . ',' . + '"' . $memberIdToName[$payerId] . '",' . + (float)$memberIdToWeight[$payerId] . ',' . + $memberIdToActive[$payerId] . ',' . + '"' . implode(',', $owerNames) . '",' . + $bill['repeat'] . ',' . + $bill['repeatfreq'] . ',' . + $bill['repeatallactive'] . ',' . + $bill['repeatuntil'] . ',' . + $bill['categoryid'] . ',' . + $bill['paymentmode'] . ',' . + $bill['paymentmodeid'] . ',' . + '"' . urlencode($bill['comment']) . '"' . + "\n" + ); + } + } + + /** + * @param $handler + * @param array $categories + * @return void + */ + protected function writeCategories($handler, array $categories): void { + if (count($categories) > 0) { + fwrite($handler, "\n"); + fwrite($handler, 'categoryname,categoryid,icon,color' . "\n"); + + foreach ($categories as $id => $category) { + fwrite( + $handler, + '"' . $category['name'] . '",' . + (int)$id . ',' . + '"' . $category['icon'] . '",' . + '"' . $category['color'] . '"' . + "\n" + ); + } + } + } + + /** + * @param $handler + * @param array $paymentModes + * @return void + */ + protected function writePaymentModes($handler, array $paymentModes): void { + if (count($paymentModes) > 0) { + fwrite($handler, "\n"); + fwrite($handler, 'paymentmodename,paymentmodeid,icon,color' . "\n"); + + foreach ($paymentModes as $id => $paymentMode) { + fwrite( + $handler, + '"' . $paymentMode['name'] . '",' . + (int)$id . ',' . + '"' . $paymentMode['icon'] . '",' . + '"' . $paymentMode['color'] . '"' . + "\n" + ); + } + } + } + + /** + * @param $handler + * @param array $currencies + * @param string|null $mainCurrencyName + * @return void + */ + protected function writeCurrencies($handler, array $currencies, ?string $mainCurrencyName): void { + if (count($currencies) > 0) { + fwrite($handler, "\n"); + fwrite($handler, 'currencyname,exchange_rate' . "\n"); + + // main currency + fwrite($handler, '"' . $mainCurrencyName . '",1' . "\n"); + + foreach ($currencies as $currency) { + fwrite( + $handler, + '"' . $currency['name'] . '",' . + (float)$currency['exchange_rate'] . + "\n" + ); + } + } + } +} diff --git a/lib/Service/ProjectService.php b/lib/Service/ProjectService.php index 2dc1b87e1..885c82f80 100644 --- a/lib/Service/ProjectService.php +++ b/lib/Service/ProjectService.php @@ -26,7 +26,6 @@ use OCP\IUserManager; use OCP\IDBConnection; use OCP\IDateTimeZone; -use OCP\Files\Folder; use OCP\Files\IRootFolder; use DateTimeImmutable; @@ -5097,310 +5096,6 @@ public function deleteCircleShare(string $projectid, int $shid, ?string $fromUse return $response; } - /** - * Export settlement plan in CSV - * - * @param string $projectid - * @param string $userId - * @param int|null $centeredOn - * @param int|null $maxTimestamp - * @return array - */ - public function exportCsvSettlement(string $projectid, string $userId, ?int $centeredOn = null, ?int $maxTimestamp = null): array { - // create export directory if needed - $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); - $userFolder = $this->root->getUserFolder($userId); - $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); - if ($msg !== '') { - return ['message' => $msg]; - } - $folder = $userFolder->get($outPath); - - // create file - if ($folder->nodeExists($projectid.'-settlement.csv')) { - $folder->get($projectid.'-settlement.csv')->delete(); - } - $file = $folder->newFile($projectid.'-settlement.csv'); - $handler = $file->fopen('w'); - fwrite( - $handler, - '"' . $this->trans->t('Who pays?') - . '","' . $this->trans->t('To whom?') - . '","' . $this->trans->t('How much?') - . '"' . "\n" - ); - $settlement = $this->getProjectSettlement($projectid, $centeredOn, $maxTimestamp); - $transactions = $settlement['transactions']; - - $members = $this->getMembers($projectid); - $memberIdToName = []; - foreach ($members as $member) { - $memberIdToName[$member['id']] = $member['name']; - } - - foreach ($transactions as $transaction) { - fwrite( - $handler, - '"' . $memberIdToName[$transaction['from']] - . '","' . $memberIdToName[$transaction['to']] - . '",' . (float) $transaction['amount'] - . "\n" - ); - } - - fclose($handler); - $file->touch(); - return ['path' => $outPath . '/' . $projectid . '-settlement.csv']; - } - - /** - * Create directory where things will be exported - * - * @param Folder $userFolder - * @param string $outPath - * @return string - */ - private function createAndCheckExportDirectory(Folder $userFolder, string $outPath): string { - if (!$userFolder->nodeExists($outPath)) { - $userFolder->newFolder($outPath); - } - if ($userFolder->nodeExists($outPath)) { - $folder = $userFolder->get($outPath); - if ($folder->getType() !== FileInfo::TYPE_FOLDER) { - return $this->trans->t('%1$s is not a folder', [$outPath]); - } elseif (!$folder->isCreatable()) { - return $this->trans->t('%1$s is not writeable', [$outPath]); - } else { - return ''; - } - } else { - return $this->trans->t('Impossible to create %1$s', [$outPath]); - } - } - - /** - * @param string $projectid - * @param string $userId - * @param int|null $tsMin - * @param int|null $tsMax - * @param int|null $paymentModeId - * @param int|null $category - * @param float|null $amountMin - * @param float|null $amountMax - * @param bool $showDisabled - * @param int|null $currencyId - * @return array - * @throws \OCP\DB\Exception - * @throws \OCP\Files\NotFoundException - * @throws \OCP\Files\NotPermittedException - * @throws \OC\User\NoUserException - */ - public function exportCsvStatistics(string $projectid, string $userId, ?int $tsMin = null, ?int $tsMax = null, - ?int $paymentModeId = null, ?int $category = null, - ?float $amountMin = null, ?float $amountMax = null, - bool $showDisabled = true, ?int $currencyId = null): array { - // create export directory if needed - $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); - $userFolder = $this->root->getUserFolder($userId); - $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); - if ($msg !== '') { - return ['message' => $msg]; - } - $folder = $userFolder->get($outPath); - - // create file - if ($folder->nodeExists($projectid.'-stats.csv')) { - $folder->get($projectid.'-stats.csv')->delete(); - } - $file = $folder->newFile($projectid.'-stats.csv'); - $handler = $file->fopen('w'); - fwrite( - $handler, - $this->trans->t('Member name') - . ',' . $this->trans->t('Paid') - . ',' . $this->trans->t('Spent') - . ',' . $this->trans->t('Balance') - . "\n" - ); - $allStats = $this->getProjectStatistics( - $projectid, 'lowername', $tsMin, $tsMax, $paymentModeId, - $category, $amountMin, $amountMax, $showDisabled, $currencyId - ); - $stats = $allStats['stats']; - - foreach ($stats as $stat) { - fwrite( - $handler, - '"' . $stat['member']['name'] - . '",' . (float) $stat['paid'] - . ',' . (float) $stat['spent'] - . ',' . (float) $stat['balance'] - . "\n" - ); - } - - fclose($handler); - $file->touch(); - return ['path' => $outPath . '/' . $projectid . '-stats.csv']; - } - - /** - * Export project in CSV - * - * @param string $projectid - * @param string|null $name - * @param string $userId - * @return array - * @throws \OCP\Files\NotFoundException - * @throws \OCP\Files\NotPermittedException - * @throws \OC\User\NoUserException - */ - public function exportCsvProject(string $projectid, string $userId, ?string $name = null): array { - // create export directory if needed - $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); - $userFolder = $this->root->getUserFolder($userId); - $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); - if ($msg !== '') { - return ['message' => $msg]; - } - $folder = $userFolder->get($outPath); - - $projectInfo = $this->getProjectInfo($projectid); - - // create file - $filename = $projectid.'.csv'; - if ($name !== null) { - $filename = $name; - if (!Utils::endswith($filename, '.csv')) { - $filename .= '.csv'; - } - } - if ($folder->nodeExists($filename)) { - $folder->get($filename)->delete(); - } - $file = $folder->newFile($filename); - $handler = $file->fopen('w'); - // members - fwrite( - $handler, - "name,weight,active,color\n" - ); - $members = $projectInfo['members']; - $memberIdToName = []; - $memberIdToWeight = []; - $memberIdToActive = []; - foreach ($members as $member) { - $memberIdToName[$member['id']] = $member['name']; - $memberIdToWeight[$member['id']] = $member['weight']; - $memberIdToActive[$member['id']] = (int) $member['activated']; - $c = $member['color']; - fwrite( - $handler, - '"' . $member['name'] . '",' - . (float) $member['weight'] . ',' - . (int) $member['activated'] . ',"' - . sprintf("#%02x%02x%02x", $c['r'] ?? 0, $c['g'] ?? 0, $c['b'] ?? 0) . '"' - . "\n" - ); - } - // bills - fwrite($handler, "\n"); - fwrite( - $handler, - "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,repeatfreq,repeatallactive,repeatuntil,categoryid,paymentmode,paymentmodeid,comment\n" - ); - $bills = $this->getBills($projectid); - foreach ($bills as $bill) { - $owerNames = []; - foreach ($bill['owers'] as $ower) { - $owerNames[] = $ower['name']; - } - $owersTxt = implode(',', $owerNames); - - $payer_id = $bill['payer_id']; - $payer_name = $memberIdToName[$payer_id]; - $payer_weight = $memberIdToWeight[$payer_id]; - $payer_active = $memberIdToActive[$payer_id]; - $dateTime = DateTime::createFromFormat('U', $bill['timestamp']); - $oldDateStr = $dateTime->format('Y-m-d'); - fwrite( - $handler, - '"' . $bill['what'] . '",' - . (float) $bill['amount'] . ',' - . $oldDateStr . ',' - . $bill['timestamp'] . ',"' - . $payer_name . '",' - . (float) $payer_weight . ',' - . $payer_active . ',"' - . $owersTxt . '",' - . $bill['repeat'] . ',' - . $bill['repeatfreq'] . ',' - . $bill['repeatallactive'] .',' - . $bill['repeatuntil'] . ',' - . $bill['categoryid'] . ',' - . $bill['paymentmode'] . ',' - . $bill['paymentmodeid'] . ',"' - . urlencode($bill['comment']) . '"' - . "\n" - ); - } - - // write categories - $categories = $projectInfo['categories']; - if (count($categories) > 0) { - fwrite($handler, "\n"); - fwrite($handler, "categoryname,categoryid,icon,color\n"); - foreach ($categories as $id => $cat) { - fwrite( - $handler, - '"' . $cat['name'] . '",' . - (int) $id . ',"' . - $cat['icon'] . '","' . - $cat['color'] . '"' . - "\n" - ); - } - } - - // write payment modes - $paymentModes = $projectInfo['paymentmodes']; - if (count($paymentModes) > 0) { - fwrite($handler, "\n"); - fwrite($handler, "paymentmodename,paymentmodeid,icon,color\n"); - foreach ($paymentModes as $id => $pm) { - fwrite( - $handler, - '"' . $pm['name'] . '",' . - (int) $id . ',"' . - $pm['icon'] . '","' . - $pm['color'] . '"' . - "\n" - ); - } - } - - // write currencies - $currencies = $projectInfo['currencies']; - if (count($currencies) > 0) { - fwrite($handler, "\n"); - fwrite($handler, "currencyname,exchange_rate\n"); - // main currency - fwrite($handler, '"' . $projectInfo['currencyname'] . '",1' . "\n"); - foreach ($currencies as $cur) { - fwrite( - $handler, - '"' . $cur['name'] - . '",' . (float) $cur['exchange_rate'] - . "\n" - ); - } - } - - fclose($handler); - $file->touch(); - return ['path' => $outPath . '/' . $filename]; - } - /** * Wrap the import process in an atomic DB transaction * This increases insert performance a lot @@ -5963,115 +5658,6 @@ public function importSWProject(string $path, string $userId): array { } } - /** - * auto export - * triggered by NC cron job - * - * export projects - */ - public function cronAutoExport(): void { - date_default_timezone_set('UTC'); - // last day - $now = new DateTime(); - $y = $now->format('Y'); - $m = $now->format('m'); - $d = $now->format('d'); - - // get begining of today - $dateMaxDay = new DateTime($y.'-'.$m.'-'.$d); - $maxDayTimestamp = $dateMaxDay->getTimestamp(); - $minDayTimestamp = $maxDayTimestamp - 24*60*60; - - $dateMaxDay->modify('-1 day'); - $dailySuffix = '_'.$this->trans->t('daily').'_'.$dateMaxDay->format('Y-m-d'); - - // last week - $now = new DateTime(); - while (((int) $now->format('N')) !== 1) { - $now->modify('-1 day'); - } - $y = $now->format('Y'); - $m = $now->format('m'); - $d = $now->format('d'); - $dateWeekMax = new DateTime($y.'-'.$m.'-'.$d); - $maxWeekTimestamp = $dateWeekMax->getTimestamp(); - $minWeekTimestamp = $maxWeekTimestamp - 7*24*60*60; - $dateWeekMin = new DateTime($y.'-'.$m.'-'.$d); - $dateWeekMin->modify('-7 day'); - $weeklySuffix = '_'.$this->trans->t('weekly').'_'.$dateWeekMin->format('Y-m-d'); - - // last month - $now = new DateTime(); - while (((int) $now->format('d')) !== 1) { - $now->modify('-1 day'); - } - $y = $now->format('Y'); - $m = $now->format('m'); - $d = $now->format('d'); - $dateMonthMax = new DateTime($y.'-'.$m.'-'.$d); - $maxMonthTimestamp = $dateMonthMax->getTimestamp(); - $now->modify('-1 day'); - while (((int) $now->format('d')) !== 1) { - $now->modify('-1 day'); - } - $y = (int) $now->format('Y'); - $m = (int) $now->format('m'); - $d = (int) $now->format('d'); - $dateMonthMin = new DateTime($y.'-'.$m.'-'.$d); - $minMonthTimestamp = $dateMonthMin->getTimestamp(); - $monthlySuffix = '_'.$this->trans->t('monthly').'_'.$dateMonthMin->format('Y-m'); - -// $weekFilterArray = []; -// $weekFilterArray['tsmin'] = $minWeekTimestamp; -// $weekFilterArray['tsmax'] = $maxWeekTimestamp; -// $dayFilterArray = []; -// $dayFilterArray['tsmin'] = $minDayTimestamp; -// $dayFilterArray['tsmax'] = $maxDayTimestamp; -// $monthFilterArray = []; -// $monthFilterArray['tsmin'] = $minMonthTimestamp; -// $monthFilterArray['tsmax'] = $maxMonthTimestamp; - - $qb = $this->db->getQueryBuilder(); - - foreach ($this->userManager->search('') as $u) { - $uid = $u->getUID(); - $outPath = $this->config->getUserValue($uid, 'cospend', 'outputDirectory', '/Cospend'); - - $qb->select('id', 'name', 'autoexport') - ->from('cospend_projects') - ->where( - $qb->expr()->eq('userid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)) - ) - ->andWhere( - $qb->expr()->neq('autoexport', $qb->createNamedParameter(Application::FREQUENCIES['no'], IQueryBuilder::PARAM_STR)) - ); - $req = $qb->executeQuery(); - - $dbProjectId = null; - while ($row = $req->fetch()) { - $dbProjectId = $row['id']; - $autoexport = $row['autoexport']; - - $suffix = $dailySuffix; - // TODO add suffix for all frequencies - if ($autoexport === Application::FREQUENCIES['weekly']) { - $suffix = $weeklySuffix; - } elseif ($autoexport === Application::FREQUENCIES['monthly']) { - $suffix = $monthlySuffix; - } - // check if file already exists - $exportName = $dbProjectId . $suffix . '.csv'; - - $userFolder = $this->root->getUserFolder($uid); - if (!$userFolder->nodeExists($outPath . '/' . $exportName)) { - $this->exportCsvProject($dbProjectId, $uid, $exportName); - } - } - $req->closeCursor(); - $qb = $qb->resetQueryParts(); - } - } - /** * Search bills with query string * diff --git a/tests/php/Service/ExportServiceTest.php b/tests/php/Service/ExportServiceTest.php new file mode 100644 index 000000000..4161c4ce4 --- /dev/null +++ b/tests/php/Service/ExportServiceTest.php @@ -0,0 +1,325 @@ +translation = $this->createMock(IL10N::class); + $this->config = $this->createMock(IConfig::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->dbConnection = $this->createMock(IDBConnection::class); + $this->projectService = $this->createMock(ProjectService::class); + + $this->exportService = new ExportService( + $this->translation, + $this->config, + $this->userManager, + $this->rootFolder, + $this->logger, + $this->dbConnection, + $this->projectService + ); + } + + public function testExportCsvProjectUserFolderNoPermission(): void { + $this->rootFolder + ->method('getUserFolder') + ->willThrowException(new NotPermittedException('not permitted exception')); + $this->assertEquals( + ['message' => $this->translation->t('Access denied')], + $this->exportService->exportCsvProject('project-id', 'user-id') + ); + } + + public function testExportCsvProjectUserDoesNotExist(): void { + $this->rootFolder + ->method('getUserFolder') + ->willThrowException(new NoUserException('no user exception')); + $this->assertEquals( + ['message' => $this->translation->t('Access denied')], + $this->exportService->exportCsvProject('project-id', 'user-id') + ); + } + + public function testExportCsvProjectFolderCannotBeCreated(): void { + $projectId = 'project-id'; + $userId = 'user-id'; + + $userFolder = $this->createStub(Folder::class); + $this->rootFolder + ->method('getUserFolder') + ->with($userId) + ->willReturn($userFolder); + + $outputPath = 'output-path'; + $this->config + ->method('getUserValue') + ->with($userId) + ->willReturn($outputPath); + + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(false); + $userFolder->method('newFolder') + ->with($outputPath) + ->willThrowException(new NotPermittedException('not permitted exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(true); + $userFolder->method('get') + ->with($outputPath) + ->willThrowException(new NotFoundException('not found exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + } + + public function testExportCsvProjectFolderIsNotAFolder(): void { + $projectId = 'project-id'; + $userId = 'user-id'; + + $userFolder = $this->createStub(Folder::class); + $this->rootFolder + ->method('getUserFolder') + ->with($userId) + ->willReturn($userFolder); + + $outputPath = 'output-path'; + $this->config + ->method('getUserValue') + ->with($userId) + ->willReturn($outputPath); + + $exportFolder = $this->createStub(Folder::class); + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(true); + $userFolder->method('get') + ->with($outputPath) + ->willReturn($exportFolder); + + $exportFolder->method('getType') + ->willReturn(FileInfo::TYPE_FILE); + $this->assertEquals( + ['message' => $this->translation->t('%1$s is not a folder', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + } + + public function testExportCsvProjectFolderNotWritable(): void { + $projectId = 'project-id'; + $userId = 'user-id'; + + $userFolder = $this->createStub(Folder::class); + $this->rootFolder + ->method('getUserFolder') + ->with($userId) + ->willReturn($userFolder); + + $outputPath = 'output-path'; + $this->config + ->method('getUserValue') + ->with($userId) + ->willReturn($outputPath); + + $exportFolder = $this->createStub(Folder::class); + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(true); + $userFolder->method('get') + ->with($outputPath) + ->willReturn($exportFolder); + + $exportFolder->method('getType') + ->willReturn(FileInfo::TYPE_FOLDER); + $exportFolder->method('isCreatable') + ->willReturn(false); + $this->assertEquals( + ['message' => $this->translation->t('%1$s is not writeable', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + } + + public function testExportCsvProjectOldFileCannotBeDeleted(): void { + $projectId = 'project-id'; + $userId = 'user-id'; + + $userFolder = $this->createStub(Folder::class); + $this->rootFolder + ->method('getUserFolder') + ->with($userId) + ->willReturn($userFolder); + + $outputPath = 'output-path'; + $this->config + ->method('getUserValue') + ->with($userId) + ->willReturn($outputPath); + + $exportFolder = $this->createStub(Folder::class); + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(true); + $userFolder->method('get') + ->with($outputPath) + ->willReturn($exportFolder); + + $exportFolder->method('getType') + ->willReturn(FileInfo::TYPE_FOLDER); + $exportFolder->method('isCreatable') + ->willReturn(true); + + $filename = $projectId . '.csv'; + + $exportFolder->method('nodeExists') + ->with($filename) + ->willReturn(true); + $exportFolder->method('get') + ->with($filename) + ->willThrowException(new NotFoundException('not found exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + + $fileNode = $this->createStub(Node::class); + $exportFolder->method('get') + ->with($filename) + ->willReturn($fileNode); + $fileNode->method('delete') + ->willThrowException(new InvalidPathException('invalid path exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + + $fileNode->method('delete') + ->willThrowException(new NotFoundException('not found exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + + $fileNode->method('delete') + ->willThrowException(new NotPermittedException('not permitted exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + } + + public function testExportCsvProjectFileCannotBeCreated(): void { + $projectId = 'project-id'; + $userId = 'user-id'; + + $userFolder = $this->createStub(Folder::class); + $this->rootFolder + ->method('getUserFolder') + ->with($userId) + ->willReturn($userFolder); + + $outputPath = 'output-path'; + $this->config + ->method('getUserValue') + ->with($userId) + ->willReturn($outputPath); + + $exportFolder = $this->createStub(Folder::class); + $userFolder->method('nodeExists') + ->with($outputPath) + ->willReturn(true); + $userFolder->method('get') + ->with($outputPath) + ->willReturn($exportFolder); + + $exportFolder->method('getType') + ->willReturn(FileInfo::TYPE_FOLDER); + $exportFolder->method('isCreatable') + ->willReturn(true); + + $filename = $projectId . '.csv'; + $exportFolder->method('nodeExists') + ->with($filename) + ->willReturn(false); + $exportFolder->method('newFile') + ->with($filename) + ->willThrowException(new NotPermittedException('not permitted exception')); + $this->assertEquals( + ['message' => $this->translation->t('Impossible to create %1$s', [$outputPath])], + $this->exportService->exportCsvProject($projectId, $userId) + ); + } +}