diff --git a/appinfo/routes.php b/appinfo/routes.php index 050db4d07d..30f19f9237 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -102,13 +102,18 @@ // import ['name' => 'import#previewImportTable', 'url' => '/import-preview/table/{tableId}', 'verb' => 'POST'], - ['name' => 'import#importInTable', 'url' => '/import/table/{tableId}', 'verb' => 'POST'], + ['name' => 'import#scheduleImportInTable', 'url' => '/import/table/{tableId}/jobs', 'verb' => 'POST'], + ['name' => 'import#scheduleImportInView', 'url' => '/import/view/{viewId}/jobs', 'verb' => 'POST'], ['name' => 'import#previewImportView', 'url' => '/import-preview/view/{viewId}', 'verb' => 'POST'], - ['name' => 'import#importInView', 'url' => '/import/view/{viewId}', 'verb' => 'POST'], ['name' => 'import#previewUploadImportTable', 'url' => '/importupload-preview/table/{tableId}', 'verb' => 'POST'], - ['name' => 'import#importUploadInTable', 'url' => '/importupload/table/{tableId}', 'verb' => 'POST'], + ['name' => 'import#scheduleImportUploadInTable', 'url' => '/importupload/table/{tableId}/jobs', 'verb' => 'POST'], ['name' => 'import#previewUploadImportView', 'url' => '/importupload-preview/view/{viewId}', 'verb' => 'POST'], + ['name' => 'import#scheduleImportUploadInView', 'url' => '/importupload/view/{viewId}/jobs', 'verb' => 'POST'], + // deprecated endpoints + ['name' => 'import#importUploadInTable', 'url' => '/importupload/table/{tableId}', 'verb' => 'POST'], ['name' => 'import#importUploadInView', 'url' => '/importupload/view/{viewId}', 'verb' => 'POST'], + ['name' => 'import#importInTable', 'url' => '/import/table/{tableId}', 'verb' => 'POST'], + ['name' => 'import#importInView', 'url' => '/import/view/{viewId}', 'verb' => 'POST'], // search ['name' => 'search#all', 'url' => '/search/all', 'verb' => 'GET'], diff --git a/cypress/e2e/tables-import.cy.js b/cypress/e2e/tables-import.cy.js index 342872c733..f8cfc13a1c 100644 --- a/cypress/e2e/tables-import.cy.js +++ b/cypress/e2e/tables-import.cy.js @@ -30,15 +30,9 @@ describe('Import csv', () => { cy.get('.modal__content button').contains('Preview').click() cy.get('.file_import__preview tbody tr').should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*/jobs' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() - cy.wait('@importUploadReq') - cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '3') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') + cy.wait('@importUploadReq').its('response.statusCode').should('equal', 200) }) it('Import csv from device', () => { @@ -50,15 +44,9 @@ describe('Import csv', () => { cy.get('.modal__content button').contains('Preview').click() cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*/jobs' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() - cy.wait('@importUploadReq') - cy.get('[data-cy="importResultColumnsFound"]', { timeout: 20000 }).should('contain.text', '4') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '3') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') + cy.wait('@importUploadReq').its('response.statusCode').should('equal', 200) }) it('Import csv from device with updating of existent files', () => { @@ -83,16 +71,9 @@ describe('Import csv', () => { cy.get('.modal__content button').contains('Preview').click() cy.get('.file_import__preview tbody tr', { timeout: 20000 }).should('have.length', 3) - cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/importupload/table/*/jobs' }).as('importUploadReq') cy.get('.modal__content button').contains('Import').click() - cy.wait('@importUploadReq') - cy.get('[data-cy="importResultColumnsFound"]', { timeout: 20000 }).should('contain.text', '2') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '3') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowsUpdated"]').should('contain.text', '1') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') + cy.wait('@importUploadReq').its('response.statusCode').should('equal', 200) }) }) @@ -118,17 +99,10 @@ describe('Import csv from Files file action', () => { cy.intercept({ method: 'POST', - url: '**/apps/tables/import/table/*' + url: '**/apps/tables/import/table/*/jobs', }).as('importNewTableReq') cy.get('[data-cy="fileActionImportButton"]').click({ force: true }) cy.wait('@importNewTableReq').its('response.statusCode').should('equal', 200) - - cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '0') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '4') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '3') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') }) it('Import to existing table', () => { @@ -141,17 +115,10 @@ describe('Import csv from Files file action', () => { cy.intercept({ method: 'POST', - url: '**/apps/tables/import/table/*' + url: '**/apps/tables/import/table/*/jobs', }).as('importExistingTableReq') - cy.get('[data-cy="fileActionImportButton"]').click({force: true}) + cy.get('[data-cy="fileActionImportButton"]').click({ force: true }) cy.wait('@importExistingTableReq').its('response.statusCode').should('equal', 200) - - cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '3') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') }) }) diff --git a/cypress/e2e/tables-table.cy.js b/cypress/e2e/tables-table.cy.js index 50b86646d1..4ef9f0f193 100644 --- a/cypress/e2e/tables-table.cy.js +++ b/cypress/e2e/tables-table.cy.js @@ -51,15 +51,9 @@ describe('Manage a table', () => { cy.get('.file-picker button span').contains('Import').click() cy.get('.modal__content button').contains('Preview').click() cy.get('.file_import__preview tbody tr').should('have.length', 4) - cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*'}).as('importUploadReq') + cy.intercept({ method: 'POST', url: '**/apps/tables/import/table/*/jobs'}).as('importUploadReq') cy.get('.modal__content button').contains('Import').scrollIntoView().click() - cy.wait('@importUploadReq') - cy.get('[data-cy="importResultColumnsFound"]').should('contain.text', '4') - cy.get('[data-cy="importResultColumnsMatch"]').should('contain.text', '0') - cy.get('[data-cy="importResultColumnsCreated"]').should('contain.text', '4') - cy.get('[data-cy="importResultRowsInserted"]').should('contain.text', '3') - cy.get('[data-cy="importResultParsingErrors"]').should('contain.text', '0') - cy.get('[data-cy="importResultRowErrors"]').should('contain.text', '0') + cy.wait('@importUploadReq').its('response.statusCode').should('equal', 200) }) it('Update description', () => { diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 80f9d92bd0..fd54a40b88 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -35,6 +35,8 @@ class ActivityManager { public const SUBJECT_ROW_UPDATE = 'row_update'; public const SUBJECT_ROW_DELETE = 'row_delete'; + public const SUBJECT_IMPORT_FINISHED = 'import_finished'; + public function __construct( private readonly IManager $manager, private readonly IFactory $l10nFactory, @@ -135,9 +137,11 @@ private function createEvent($objectType, $object, $subject, $additionalParams = case self::SUBJECT_ROW_DELETE: $subjectParams['row'] = $object; break; - default: - throw new \Exception('Unknown subject for activity.'); + case self::SUBJECT_IMPORT_FINISHED: + $subjectParams['importStats'] = $additionalParams['importStats'] ?? null; break; + default: + throw new \Exception(sprintf('Unknown subject "%s" for activity.', $subject)); } if ($subject === self::SUBJECT_ROW_UPDATE) { @@ -200,7 +204,7 @@ private function sendToUsers(IEvent $event, $object) { } } - public function getActivityFormat($language, $subjectIdentifier, $subjectParams = [], $ownActivity = false) { + public function getActivitySubject($language, $subjectIdentifier, $subjectParams = [], $ownActivity = false) { $subject = ''; $l = $this->l10nFactory->get(Application::APP_ID, $language); @@ -248,10 +252,34 @@ public function getActivityFormat($language, $subjectIdentifier, $subjectParams case self::SUBJECT_ROW_DELETE: $subject = $ownActivity ? $l->t('You have deleted the row {row} in table {table}') : $l->t('{user} has deleted the row {row} in table {table}'); break; + case self::SUBJECT_IMPORT_FINISHED: + $subject = $ownActivity ? $l->t('You have imported file to table {table}') : $l->t('{user} has imported file to table {table}'); + break; default: break; } return $subject; } + + public function getActivityMessage($language, $subjectIdentifier) { + $l = $this->l10nFactory->get(Application::APP_ID, $language); + + switch ($subjectIdentifier) { + case self::SUBJECT_IMPORT_FINISHED: + $lines = [ + $l->t('Found columns: {foundColumnsCount}'), + $l->t('Matching columns: {matchingColumnsCount}'), + $l->t('Created columns: {createdColumnsCount}'), + $l->t('Inserted rows: {insertedRowsCount}'), + $l->t('Updated rows: {updatedRowsCount}'), + $l->t('Value parsing errors: {errorsParsingCount}'), + $l->t('Row creation errors: {errorsCount}'), + ]; + return implode("\n", $lines); + + default: + return null; + } + } } diff --git a/lib/Activity/TablesProvider.php b/lib/Activity/TablesProvider.php index 246efb7c24..2ec665acf0 100644 --- a/lib/Activity/TablesProvider.php +++ b/lib/Activity/TablesProvider.php @@ -12,6 +12,7 @@ use OCP\Activity\IProvider; use OCP\IURLGenerator; use OCP\IUserManager; +use Psr\Log\LoggerInterface; class TablesProvider implements IProvider { @@ -20,6 +21,7 @@ public function __construct( private IURLGenerator $urlGenerator, private ActivityManager $activityManager, private IUserManager $userManager, + private LoggerInterface $logger, ) { } @@ -40,7 +42,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): $user = $this->userManager->get($author); if ($user !== null) { - $params = [ + $subjectParameters = [ 'user' => [ 'type' => 'user', 'id' => $author, @@ -49,7 +51,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): ]; $event->setAuthor($author); } else { - $params = [ + $subjectParameters = [ 'user' => [ 'type' => 'user', 'id' => 'deleted_users', @@ -65,7 +67,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): 'name' => $event->getObjectName(), 'link' => $this->tablesUrl('/table/' . $event->getObjectId()), ]; - $params['table'] = $table; + $subjectParameters['table'] = $table; $event->setLink($this->tablesUrl('/table/' . $event->getObjectId())); } @@ -76,19 +78,19 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): 'name' => (string)$subjectParams['table']['title'], 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id']), ]; - $params['table'] = $table; + $subjectParameters['table'] = $table; $row = [ 'type' => 'highlight', 'id' => (string)$event->getObjectId(), 'name' => '#' . $event->getObjectId(), 'link' => $this->tablesUrl('/table/' . $subjectParams['table']['id'] . '/row/' . $event->getObjectId()), ]; - $params['row'] = $row; + $subjectParameters['row'] = $row; $event->setLink($this->tablesUrl('/table/' . $subjectParams['table']['id'] . '/row/' . $event->getObjectId())); if ($event->getSubject() === ActivityManager::SUBJECT_ROW_UPDATE) { foreach ($subjectParams['changeCols'] as $changeCol) { - $params['col-' . $changeCol['id']] = [ + $subjectParameters['col-' . $changeCol['id']] = [ 'type' => 'highlight', 'id' => (string)$changeCol['id'], 'name' => $changeCol['name'] ?? '', @@ -98,7 +100,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): } if (array_key_exists('before', $subjectParams) && is_string($subjectParams['before'])) { - $params['before'] = [ + $subjectParameters['before'] = [ 'type' => 'highlight', 'id' => $subjectParams['before'], 'name' => $subjectParams['before'] ?? '' @@ -106,17 +108,30 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null): } if (array_key_exists('after', $subjectParams)) { - $params['after'] = [ + $subjectParameters['after'] = [ 'type' => 'highlight', 'id' => (string)$subjectParams['after'], 'name' => $subjectParams['after'] ?? '' ]; } + $messageParameters = []; + if ($event->getSubject() === ActivityManager::SUBJECT_IMPORT_FINISHED) { + $messageParameters['{foundColumnsCount}'] = $subjectParams['importStats']['foundColumnsCount']; + $messageParameters['{matchingColumnsCount}'] = $subjectParams['importStats']['matchingColumnsCount']; + $messageParameters['{createdColumnsCount}'] = $subjectParams['importStats']['createdColumnsCount']; + $messageParameters['{insertedRowsCount}'] = $subjectParams['importStats']['insertedRowsCount']; + $messageParameters['{updatedRowsCount}'] = $subjectParams['importStats']['updatedRowsCount']; + $messageParameters['{errorsParsingCount}'] = $subjectParams['importStats']['errorsParsingCount']; + $messageParameters['{errorsCount}'] = $subjectParams['importStats']['errorsCount']; + } + try { - $subject = $this->activityManager->getActivityFormat($language, $subjectIdentifier, $subjectParams, $ownActivity); - $this->setSubjects($event, $subject, $params); + $subject = $this->activityManager->getActivitySubject($language, $subjectIdentifier, $subjectParams, $ownActivity); + $message = $this->activityManager->getActivityMessage($language, $subjectIdentifier); + $this->parseEvent($event, $subject, $subjectParameters, $message, $messageParameters); } catch (\Exception $e) { + $this->logger->warning('Could not parse activity: ' . $e->getMessage(), ['exception' => $e]); } return $event; @@ -144,10 +159,10 @@ private function tablesUrl(string $endpoint) { return $this->urlGenerator->linkToRouteAbsolute('tables.page.index') . '#/' . trim($endpoint, '/'); } - private function setSubjects(IEvent $event, $subject, array $parameters) { + private function parseEvent(IEvent $event, string $subject, array $subjectParameters, ?string $message, array $messageParameters = []) { $placeholders = $replacements = $richParameters = []; - foreach ($parameters as $placeholder => $parameter) { + foreach ($subjectParameters as $placeholder => $parameter) { $placeholders[] = '{' . $placeholder . '}'; if (is_array($parameter) && array_key_exists('name', $parameter)) { $replacements[] = $parameter['name']; @@ -157,8 +172,12 @@ private function setSubjects(IEvent $event, $subject, array $parameters) { } } - $event->setParsedSubject(str_replace($placeholders, $replacements, $subject)) + $event->setSubject($subject, $subjectParameters) + ->setParsedSubject(str_replace($placeholders, $replacements, $subject)) ->setRichSubject($subject, $richParameters); - $event->setSubject($subject, $parameters); + + if ($message) { + $event->setParsedMessage(strtr($message, $messageParameters)); + } } } diff --git a/lib/BackgroundJob/ImportTableJob.php b/lib/BackgroundJob/ImportTableJob.php new file mode 100644 index 0000000000..50fd353241 --- /dev/null +++ b/lib/BackgroundJob/ImportTableJob.php @@ -0,0 +1,80 @@ +userSession->getUser(); + + try { + $user = $this->userManager->get($userId); + $this->userSession->setUser($user); + + $importStats = $this->importService + ->importV2( + $userId, + $tableId, + $viewId, + $argument['user_file_path'], + $argument['import_file_name'], + $argument['create_missing_columns'], + $argument['columns_config'] + ); + } finally { + $this->userSession->setUser($oldUser); + } + + if (!$tableId && $viewId) { + $tableId = $this->viewMapper->find($viewId)->getTableId(); + } + + $this->activityManager->triggerEvent( + objectType: ActivityManager::TABLES_OBJECT_TABLE, + object: $this->tableMapper->find($tableId), + subject: ActivityManager::SUBJECT_IMPORT_FINISHED, + additionalParams: [ + 'importStats' => $importStats, + ], + author: $userId + ); + } +} diff --git a/lib/Controller/ImportController.php b/lib/Controller/ImportController.php index a491c8b90d..24df2b6b84 100644 --- a/lib/Controller/ImportController.php +++ b/lib/Controller/ImportController.php @@ -42,7 +42,6 @@ class ImportController extends Controller { use Errors; - public function __construct( IRequest $request, LoggerInterface $logger, @@ -65,6 +64,9 @@ public function previewImportTable(int $tableId, String $path): DataResponse { }); } + /** + * @deprecated Use {@link scheduleImportInTable} instead + */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] public function importInTable(int $tableId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse { @@ -74,6 +76,17 @@ public function importInTable(int $tableId, String $path, bool $createMissingCol }); } + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] + public function scheduleImportInTable(int $tableId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse { + return $this->handleError(function () use ($tableId, $path, $createMissingColumns, $columnsConfig) { + // minimal permission is checked, creating columns requires MANAGE permissions - currently tested on service layer + $this->service->scheduleImport($tableId, null, $path, $createMissingColumns, $columnsConfig); + + return new DataResponse(); + }); + } + #[NoAdminRequired] #[UserRateLimit(limit: 20, period: 60)] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] @@ -83,6 +96,9 @@ public function previewImportView(int $viewId, String $path): DataResponse { }); } + /** + * @deprecated Use {@link scheduleImportInView} instead + */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] public function importInView(int $viewId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse { @@ -92,6 +108,17 @@ public function importInView(int $viewId, String $path, bool $createMissingColum }); } + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] + public function scheduleImportInView(int $viewId, String $path, bool $createMissingColumns = true, array $columnsConfig = []): DataResponse { + return $this->handleError(function () use ($viewId, $path, $createMissingColumns, $columnsConfig) { + // minimal permission is checked, creating columns requires MANAGE permissions - currently tested on service layer + $this->service->scheduleImport(null, $viewId, $path, $createMissingColumns, $columnsConfig); + + return new DataResponse(); + }); + } + #[NoAdminRequired] #[UserRateLimit(limit: 20, period: 60)] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] @@ -107,6 +134,9 @@ public function previewUploadImportTable(int $tableId): DataResponse { } } + /** + * @deprecated Use {@link scheduleImportUploadInTable} instead + */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] public function importUploadInTable(int $tableId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse { @@ -138,6 +168,28 @@ public function previewUploadImportView(int $viewId): DataResponse { } } + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_TABLE, idParam: 'tableId')] + public function scheduleImportUploadInTable(int $tableId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse { + try { + $columnsConfigArray = json_decode($columnsConfig, true); + $file = $this->getUploadedFile('uploadfile'); + return $this->handleError(function () use ($tableId, $file, $createMissingColumns, $columnsConfigArray) { + // minimal permission is checked, creating columns requires MANAGE permissions - currently tested on service layer + $this->service->scheduleImport($tableId, null, $file['tmp_name'], $createMissingColumns, $columnsConfigArray); + + return new DataResponse(); + }); + } catch (UploadException|NotPermittedException $e) { + $this->logger->error('Upload error', ['exception' => $e]); + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @deprecated Use {@link scheduleImportUploadInView} instead + */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] public function importUploadInView(int $viewId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse { @@ -154,6 +206,25 @@ public function importUploadInView(int $viewId, bool $createMissingColumns = tru } } + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_CREATE, type: Application::NODE_TYPE_VIEW, idParam: 'viewId')] + public function scheduleImportUploadInView(int $viewId, bool $createMissingColumns = true, string $columnsConfig = ''): DataResponse { + try { + $columnsConfigArray = json_decode($columnsConfig, true); + $file = $this->getUploadedFile('uploadfile'); + return $this->handleError(function () use ($viewId, $file, $createMissingColumns, $columnsConfigArray) { + // minimal permission is checked, creating columns requires MANAGE permissions - currently tested on service layer + $this->service->scheduleImport(null, $viewId, $file['tmp_name'], $createMissingColumns, $columnsConfigArray); + + return new DataResponse(); + }); + } catch (UploadException|NotPermittedException $e) { + $this->logger->error('Upload error', ['exception' => $e]); + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + /** * @param string $key * @return array diff --git a/lib/Db/Row2Mapper.php b/lib/Db/Row2Mapper.php index 05ec003cd3..f6ecabe8cb 100644 --- a/lib/Db/Row2Mapper.php +++ b/lib/Db/Row2Mapper.php @@ -689,11 +689,15 @@ public function isRowInViewPresent(int $rowId, View $view, string $userId): bool /** * @param Row2 $row + * @param string|null $userId * @return Row2 * @throws InternalError * @throws Exception */ - public function insert(Row2 $row): Row2 { + public function insert(Row2 $row, ?string $userId = null): Row2 { + if ($userId) { + $this->userId = $userId; + } if ($row->getId()) { // if row has an id from migration or import etc. $rowSleeve = $this->createRowSleeveFromExistingData($row->getId(), $row->getTableId(), $row->getCreatedAt(), $row->getCreatedBy(), $row->getLastEditBy(), $row->getLastEditAt()); @@ -712,9 +716,15 @@ public function insert(Row2 $row): Row2 { } /** + * @param Row2 $row + * @param string|null $userId + * @return Row2 * @throws InternalError */ - public function update(Row2 $row): Row2 { + public function update(Row2 $row, ?string $userId = null): Row2 { + if ($userId) { + $this->userId = $userId; + } $changedCells = $row->getChangedCells(); // if nothing has changed if (count($changedCells) === 0) { diff --git a/lib/Model/ImportStats.php b/lib/Model/ImportStats.php new file mode 100644 index 0000000000..5c7fa296d3 --- /dev/null +++ b/lib/Model/ImportStats.php @@ -0,0 +1,21 @@ +viewService->find($viewId); + $view = $this->viewService->find($viewId, true, $userId); } catch (InternalError|MultipleObjectsReturnedException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -229,7 +229,7 @@ public function create( throw new InternalError('Cannot create column without table or view in context'); } - if (!$this->permissionsService->canCreateColumns($table)) { + if (!$this->permissionsService->canCreateColumns($table, $userId)) { throw new PermissionError('create column for the table id = ' . $table->getId() . ' is not allowed.'); } @@ -252,7 +252,6 @@ public function create( $i++; } - $time = new DateTime(); $item = Column::fromDto($columnDto); $item->setTitle($newTitle); $item->setTableId($table->getId()); diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 6e349daba5..5cb47bfc7f 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -10,12 +10,15 @@ use DateTimeImmutable; use LogicException; use OC\User\NoUserException; +use OCA\Tables\AppInfo\Application; +use OCA\Tables\BackgroundJob\ImportTableJob; use OCA\Tables\Db\Column; use OCA\Tables\Dto\Column as ColumnDto; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Model\ImportStats; use OCA\Tables\Service\ColumnTypes\IColumnTypeBusiness; use OCA\Tables\Vendor\PhpOffice\PhpSpreadsheet\Cell\Cell; use OCA\Tables\Vendor\PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -25,10 +28,15 @@ use OCA\Tables\Vendor\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\BackgroundJob\IJobList; use OCP\DB\Exception; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\IAppData; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\ITempManager; use OCP\IUserManager; use OCP\Server; use Psr\Container\ContainerExceptionInterface; @@ -49,6 +57,7 @@ class ImportService extends SuperService { private TableService $tableService; private ViewService $viewService; private IUserManager $userManager; + private IAppData $appData; private ?int $tableId = null; private ?int $viewId = null; @@ -76,6 +85,9 @@ public function __construct( TableService $tableService, ViewService $viewService, IUserManager $userManager, + private IJobList $jobList, + private ITempManager $tempManager, + IAppDataFactory $appDataFactory, ) { parent::__construct($logger, $userId, $permissionsService); $this->rootFolder = $rootFolder; @@ -84,6 +96,7 @@ public function __construct( $this->tableService = $tableService; $this->viewService = $viewService; $this->userManager = $userManager; + $this->appData = $appDataFactory->get(Application::APP_ID); } public function previewImport(?int $tableId, ?int $viewId, string $path): array { @@ -246,6 +259,7 @@ private function getPreviewData(Worksheet $worksheet): array { } /** + * @deprecated Use {@link scheduleImport} and {@link importV2} instead. * @param ?int $tableId * @param ?int $viewId * @param string $path @@ -336,6 +350,138 @@ public function import(?int $tableId, ?int $viewId, string $path, bool $createMi ]; } + /** + * @param ?int $tableId + * @param ?int $viewId + * @param string $path + * @param bool $createMissingColumns + * @throws DoesNotExistException + * @throws InternalError + * @throws MultipleObjectsReturnedException + * @throws NotFoundError + * @throws PermissionError + */ + public function scheduleImport(?int $tableId, ?int $viewId, string $path, bool $createMissingColumns = true, array $columnsConfig = []) { + if ($viewId !== null) { + $view = $this->viewService->find($viewId); + if (!$this->permissionsService->canCreateRows($view)) { + throw new PermissionError('create row at the view id = ' . $viewId . ' is not allowed.'); + } + if ($createMissingColumns && !$this->permissionsService->canManageTableById($view->getTableId())) { + throw new PermissionError('create columns at the view id = ' . $viewId . ' is not allowed.'); + } + $this->viewId = $viewId; + } + if ($tableId) { + $table = $this->tableService->find($tableId); + if (!$this->permissionsService->canCreateRows($table, 'table')) { + throw new PermissionError('create row at the view id = ' . (string)$viewId . ' is not allowed.'); + } + if ($createMissingColumns && !$this->permissionsService->canManageTable($table)) { + throw new PermissionError('create columns at the view id = ' . (string)$viewId . ' is not allowed.'); + } + $this->tableId = $tableId; + } + if (!$this->tableId && !$this->viewId) { + $e = new \Exception('Neither tableId nor viewId is given.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + if ($this->tableId && $this->viewId) { + $e = new \LogicException('Both table ID and view ID are provided, but only one of them is allowed'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + if ($this->userId === null || $this->userManager->get($this->userId) === null) { + $error = 'No user in context, can not import data. Cancel.'; + $this->logger->debug($error); + throw new InternalError($error); + } + + $importFileName = null; + // uploaded file + if (\file_exists($path)) { + $importFileName = 'import_u-' . $this->userId; + if ($this->tableId) { + $importFileName .= '_t-' . $this->tableId; + } + if ($this->viewId) { + $importFileName .= '_v-' . $this->viewId; + } + $importFileName .= '_' . \date('Y-m-d_H-i-s') . '.' . pathinfo($path, PATHINFO_EXTENSION); + + $this->getImportAppDataDir() + ->newFile($importFileName) + ->putContent(\file_get_contents($path)); + } + + $this->jobList->add( + ImportTableJob::class, + [ + 'user_id' => $this->userId, + 'table_id' => $this->tableId, + 'view_id' => $this->viewId, + 'user_file_path' => $importFileName ? null : $path, + 'import_file_name' => $importFileName, + 'create_missing_columns' => $createMissingColumns, + 'columns_config' => $columnsConfig, + ] + ); + } + + + /** + * @param string $userId + * @param ?int $tableId + * @param ?int $viewId + * @param ?string $userFilePath Path to a file in the user's storage, if the file is not uploaded + * @param ?string $importFileName Name of the file in the app data directory, if the file is uploaded + * @param bool $createMissingColumns + * @param array $columnsConfig + * @return ImportStats + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws NotFoundError + */ + public function importV2(string $userId, ?int $tableId, ?int $viewId, ?string $userFilePath, ?string $importFileName, bool $createMissingColumns = true, array $columnsConfig = []): ImportStats { + $this->userId = $userId; + $this->tableId = $tableId; + $this->viewId = $viewId; + $this->createUnknownColumns = $createMissingColumns; + $this->columnsConfig = $columnsConfig; + + try { + if ($importFileName) { + $file = $this->getImportAppDataDir()->getFile($importFileName); + $temporaryFile = $this->tempManager->getTemporaryFile('.' . $file->getExtension()); + file_put_contents($temporaryFile, $file->getContent()); + $spreadsheet = IOFactory::load($temporaryFile); + $this->loop($spreadsheet->getActiveSheet()); + } elseif ($userFilePath) { + $file = $this->rootFolder->getUserFolder($this->userId)->get($userFilePath); + $temporaryFile = $file->getStorage()->getLocalFile($file->getInternalPath()); + $spreadsheet = IOFactory::load($temporaryFile); + $this->loop($spreadsheet->getActiveSheet()); + } else { + throw new NotFoundError('No file for import given.'); + } + } catch (NotFoundException|NotPermittedException|NoUserException|InternalError|PermissionError $e) { + $this->logger->warning('Storage for user could not be found', ['exception' => $e]); + throw new NotFoundError('Storage for user could not be found', 0, $e); + } + + return new ImportStats( + count($this->columns), + $this->countMatchingColumns, + $this->countCreatedColumns, + $this->countInsertedRows, + $this->countUpdatedRows, + $this->countParsingErrors, + $this->countErrors + ); + } + /** * @param Worksheet $worksheet * @throws DoesNotExistException @@ -483,7 +629,7 @@ private function upsertRow(Row $row): void { $this->rowService->updateSet($id, $this->viewId, $data, $this->userId, $this->tableId); $this->countUpdatedRows++; } else { - $this->rowService->create($this->tableId, $this->viewId, $data); + $this->rowService->create($this->tableId, $this->viewId, $data, $this->userId); $this->countInsertedRows++; } } catch (PermissionError $e) { @@ -714,4 +860,16 @@ private function parseColumnDataType(Cell $cell): array { return $dataType; } + + /** + * @return ISimpleFolder + * @throws \OCP\Files\NotPermittedException + */ + private function getImportAppDataDir(): ISimpleFolder { + try { + return $this->appData->getFolder('import'); + } catch (NotFoundException) { + return $this->appData->newFolder('import'); + } + } } diff --git a/lib/Service/PermissionsService.php b/lib/Service/PermissionsService.php index 678a26ed5f..482ad41db2 100644 --- a/lib/Service/PermissionsService.php +++ b/lib/Service/PermissionsService.php @@ -628,7 +628,7 @@ private function basisCheck(Table|View|Context $element, string $nodeType, ?stri try { $userId = $this->preCheckUserId($userId); } catch (InternalError $e) { - $e = new \Exception('Cannot pre check the user id'); + $e = new \Exception('Cannot pre check the user id', 0, $e); $this->logger->error($e->getMessage(), ['exception' => $e]); return false; } diff --git a/lib/Service/RowService.php b/lib/Service/RowService.php index 6a92f79c57..70c7cdf3b5 100644 --- a/lib/Service/RowService.php +++ b/lib/Service/RowService.php @@ -163,6 +163,7 @@ public function find(int $rowId): Row2 { * @param int|null $tableId * @param int|null $viewId * @param RowDataInput|list $data + * @param string|null $userId * @return Row2 * * @throws BadRequestError @@ -171,7 +172,10 @@ public function find(int $rowId): Row2 { * @throws Exception * @throws InternalError */ - public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): Row2 { + public function create(?int $tableId, ?int $viewId, RowDataInput|array $data, ?string $userId = null): Row2 { + if ($userId) { + $this->userId = $userId; + } if ($this->userId === null || $this->userId === '') { $e = new \Exception('No user id in context, but needed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); @@ -192,7 +196,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): R } // security - if (!$this->permissionsService->canCreateRows($view)) { + if (!$this->permissionsService->canCreateRows($view, 'view', $this->userId)) { throw new PermissionError('create row at the view id = ' . $viewId . ' is not allowed.'); } @@ -210,7 +214,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): R } // security - if (!$this->permissionsService->canCreateRows($table, 'table')) { + if (!$this->permissionsService->canCreateRows($table, 'table', $this->userId)) { throw new PermissionError('create row at the table id = ' . $tableId . ' is not allowed.'); } @@ -232,7 +236,7 @@ public function create(?int $tableId, ?int $viewId, RowDataInput|array $data): R $row2->setTableId($tableId); $row2->setData($data); try { - $insertedRow = $this->row2Mapper->insert($row2); + $insertedRow = $this->row2Mapper->insert($row2, $this->userId); $this->eventDispatcher->dispatchTyped(new RowAddedEvent($insertedRow)); $this->activityManager->triggerEvent( @@ -550,6 +554,15 @@ public function updateSet( string $userId, ?int $tableId, ): Row2 { + if ($userId) { + $this->userId = $userId; + } + if ($this->userId === null || $this->userId === '') { + $e = new \Exception('No user id in context, but needed.'); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + try { $item = $this->getRowById($id); } catch (InternalError $e) { @@ -567,7 +580,7 @@ public function updateSet( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - if (!$this->permissionsService->canUpdateRowsByViewId($viewId)) { + if (!$this->permissionsService->canUpdateRowsByViewId($viewId, $userId)) { $e = new \Exception('Update row is not allowed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); throw new PermissionError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -613,7 +626,7 @@ public function updateSet( $this->logger->error($e->getMessage(), ['exception' => $e]); throw new NotFoundError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); } - if (!$this->permissionsService->canUpdateRowsByTableId($tableId)) { + if (!$this->permissionsService->canUpdateRowsByTableId($tableId, $userId)) { $e = new \Exception('Update row is not allowed.'); $this->logger->error($e->getMessage(), ['exception' => $e]); throw new PermissionError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); @@ -640,7 +653,7 @@ public function updateSet( } } - $updatedRow = $this->row2Mapper->update($item); + $updatedRow = $this->row2Mapper->update($item, $this->userId); $this->eventDispatcher->dispatchTyped(new RowUpdatedEvent($updatedRow, $previousData)); diff --git a/src/modules/modals/FileActionImport.vue b/src/modules/modals/FileActionImport.vue index 6ef692723e..23f55462b9 100644 --- a/src/modules/modals/FileActionImport.vue +++ b/src/modules/modals/FileActionImport.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> diff --git a/src/modules/modals/Import.vue b/src/modules/modals/Import.vue index 1e05d97edd..60554df36c 100644 --- a/src/modules/modals/Import.vue +++ b/src/modules/modals/Import.vue @@ -9,7 +9,7 @@ @closing="actionCancel">