mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added import system
This commit is contained in:
@@ -6,11 +6,19 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use App\Service\Import\ImportService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class OrganizationResource extends Resource
|
||||
{
|
||||
@@ -60,6 +68,52 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
// TODO: different disk!
|
||||
try {
|
||||
/** @var ReportDto $report */
|
||||
$report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []);
|
||||
Notification::make()
|
||||
->title('Import successful')
|
||||
->success()
|
||||
->body(
|
||||
'Imported time entries: '.$report->timeEntriesCreated.'<br>'.
|
||||
'Imported clients: '.$report->clientsCreated.'<br>'.
|
||||
'Imported projects: '.$report->projectsCreated.'<br>'.
|
||||
'Imported tasks: '.$report->tasksCreated.'<br>'.
|
||||
'Imported tags: '.$report->tagsCreated.'<br>'.
|
||||
'Imported users: '.$report->usersCreated
|
||||
)
|
||||
->persistent()
|
||||
->send();
|
||||
} catch (ImportException $exception) {
|
||||
report($exception);
|
||||
Notification::make()
|
||||
->title('Import failed, changes rolled back')
|
||||
->danger()
|
||||
->body('Message: '.$exception->getMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->tooltip(fn (Organization $record): string => "Import into {$record->name}")
|
||||
->form([
|
||||
Forms\Components\FileUpload::make('file')
|
||||
->label('File')
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->required()
|
||||
->options(function (): array {
|
||||
$select = [];
|
||||
foreach (app(ImporterProvider::class)->getImporterKeys() as $key) {
|
||||
$select[$key] = $key;
|
||||
}
|
||||
|
||||
return $select;
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
|
||||
64
app/Http/Controllers/Api/V1/ImportController.php
Normal file
64
app/Http/Controllers/Api/V1/ImportController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Requests\V1\Import\ImportRequest;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\ImportService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Import data into the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'import');
|
||||
|
||||
try {
|
||||
$report = $importService->import(
|
||||
$organization,
|
||||
$request->input('type'),
|
||||
$request->input('data'),
|
||||
$request->input('options')
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
/** @var array{
|
||||
* clients: array{
|
||||
* created: int,
|
||||
* },
|
||||
* projects: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tasks: array{
|
||||
* created: int,
|
||||
* },
|
||||
* time-entries: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tags: array{
|
||||
* created: int,
|
||||
* },
|
||||
* users: array{
|
||||
* created: int,
|
||||
* }
|
||||
* } $report Import report */
|
||||
'report' => $report->toArray(),
|
||||
], 200);
|
||||
} catch (ImportException $exception) {
|
||||
report($exception);
|
||||
|
||||
return new JsonResponse([
|
||||
'message' => $exception->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class ValidateSignature extends Middleware
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
protected array $except = [
|
||||
// 'fbclid',
|
||||
// 'utm_campaign',
|
||||
// 'utm_content',
|
||||
|
||||
30
app/Http/Requests/V1/Import/ImportRequest.php
Normal file
30
app/Http/Requests/V1/Import/ImportRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Import;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ImportRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
'data' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class ProjectStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
|
||||
@@ -25,6 +25,7 @@ class ProjectUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
|
||||
@@ -18,6 +18,7 @@ class TagStoreRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
|
||||
@@ -18,6 +18,7 @@ class TagUpdateRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
// TODO: unique
|
||||
'required',
|
||||
'string',
|
||||
'min:1',
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\OrganizationFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -18,6 +19,7 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @property string $name
|
||||
* @property bool $personal_team
|
||||
* @property User $owner
|
||||
* @property Collection<User> $users
|
||||
*
|
||||
* @method HasMany<OrganizationInvitation> teamInvitations()
|
||||
* @method static OrganizationFactory factory()
|
||||
|
||||
@@ -64,8 +64,11 @@ class User extends Authenticatable
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'email' => 'string',
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_placeholder' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
if (config('app.force_https', false) || App::isProduction()) {
|
||||
URL::forceScheme('https');
|
||||
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
])->description('Administrator users can perform any action.');
|
||||
|
||||
Jetstream::role('manager', 'Manager', [
|
||||
|
||||
38
app/Service/ColorService.php
Normal file
38
app/Service/ColorService.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
class ColorService
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const array COLORS = [
|
||||
'#ef5350',
|
||||
'#ec407a',
|
||||
'#ab47bc',
|
||||
'#7e57c2',
|
||||
'#5c6bc0',
|
||||
'#42a5f5',
|
||||
'#29b6f6',
|
||||
'#26c6da',
|
||||
'#26a69a',
|
||||
'#66bb6a',
|
||||
'#9ccc65',
|
||||
'#d4e157',
|
||||
'#ffee58',
|
||||
'#ffca28',
|
||||
'#ffa726',
|
||||
'#ff7043',
|
||||
'#8d6e63',
|
||||
'#bdbdbd',
|
||||
'#78909c',
|
||||
];
|
||||
|
||||
public function getRandomColor(): string
|
||||
{
|
||||
return self::COLORS[array_rand(self::COLORS)];
|
||||
}
|
||||
}
|
||||
132
app/Service/Import/ImportDatabaseHelper.php
Normal file
132
app/Service/Import/ImportDatabaseHelper.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @template TModel of Model
|
||||
*/
|
||||
class ImportDatabaseHelper
|
||||
{
|
||||
/**
|
||||
* @var class-string<TModel>
|
||||
*/
|
||||
private string $model;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private array $identifiers;
|
||||
|
||||
private ?array $mapIdentifierToKey = null;
|
||||
|
||||
private array $mapNewAttach = [];
|
||||
|
||||
private bool $attachToExisting;
|
||||
|
||||
private ?Closure $queryModifier;
|
||||
|
||||
private ?Closure $afterCreate;
|
||||
|
||||
private int $createdCount;
|
||||
|
||||
/**
|
||||
* @param class-string<TModel> $model
|
||||
* @param array<string> $identifiers
|
||||
*/
|
||||
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null)
|
||||
{
|
||||
$this->model = $model;
|
||||
$this->identifiers = $identifiers;
|
||||
$this->attachToExisting = $attachToExisting;
|
||||
$this->queryModifier = $queryModifier;
|
||||
$this->afterCreate = $afterCreate;
|
||||
$this->createdCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
private function getModelInstance(): Builder
|
||||
{
|
||||
return (new $this->model)->query();
|
||||
}
|
||||
|
||||
private function createEntity(array $identifierData, array $createValues): string
|
||||
{
|
||||
$model = new $this->model();
|
||||
foreach ($identifierData as $identifier => $identifierValue) {
|
||||
$model->{$identifier} = $identifierValue;
|
||||
}
|
||||
foreach ($createValues as $key => $value) {
|
||||
$model->{$key} = $value;
|
||||
}
|
||||
$model->save();
|
||||
|
||||
if ($this->afterCreate !== null) {
|
||||
($this->afterCreate)($model);
|
||||
}
|
||||
|
||||
$this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey();
|
||||
$this->createdCount++;
|
||||
|
||||
return $model->getKey();
|
||||
}
|
||||
|
||||
private function getHash(array $data): string
|
||||
{
|
||||
return md5(json_encode($data));
|
||||
}
|
||||
|
||||
public function getKey(array $identifierData, array $createValues = []): string
|
||||
{
|
||||
$this->checkMap();
|
||||
|
||||
$hash = $this->getHash($identifierData);
|
||||
if ($this->attachToExisting) {
|
||||
$key = $this->mapIdentifierToKey[$hash] ?? null;
|
||||
if ($key !== null) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $this->createEntity($identifierData, $createValues);
|
||||
} else {
|
||||
throw new \RuntimeException('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMap(): void
|
||||
{
|
||||
if ($this->mapIdentifierToKey === null) {
|
||||
$select = $this->identifiers;
|
||||
$select[] = (new $this->model())->getKeyName();
|
||||
$builder = $this->getModelInstance();
|
||||
|
||||
if ($this->queryModifier !== null) {
|
||||
$builder = ($this->queryModifier)($builder);
|
||||
}
|
||||
|
||||
$databaseEntries = $builder->select($select)
|
||||
->get();
|
||||
$this->mapIdentifierToKey = [];
|
||||
foreach ($databaseEntries as $databaseEntry) {
|
||||
$identifierData = [];
|
||||
foreach ($this->identifiers as $identifier) {
|
||||
$identifierData[$identifier] = $databaseEntry->{$identifier};
|
||||
}
|
||||
$hash = $this->getHash($identifierData);
|
||||
$this->mapIdentifierToKey[$hash] = $databaseEntry->getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getCreatedCount(): int
|
||||
{
|
||||
return $this->createdCount;
|
||||
}
|
||||
}
|
||||
30
app/Service/Import/ImportService.php
Normal file
30
app/Service/Import/ImportService.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\ImporterContract;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ImportService
|
||||
{
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
public function import(Organization $organization, string $importerType, string $data, array $options): ReportDto
|
||||
{
|
||||
/** @var ImporterContract $importer */
|
||||
$importer = app(ImporterProvider::class)->getImporter($importerType);
|
||||
$importer->init($organization);
|
||||
DB::transaction(function () use (&$importer, &$data, &$options, &$organization) {
|
||||
$importer->importData($data, $options);
|
||||
});
|
||||
|
||||
return $importer->getReport();
|
||||
}
|
||||
}
|
||||
9
app/Service/Import/Importers/ImportException.php
Normal file
9
app/Service/Import/Importers/ImportException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImportException extends \Exception
|
||||
{
|
||||
}
|
||||
16
app/Service/Import/Importers/ImporterContract.php
Normal file
16
app/Service/Import/Importers/ImporterContract.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
interface ImporterContract
|
||||
{
|
||||
public function init(Organization $organization): void;
|
||||
|
||||
public function importData(string $data, array $options): void;
|
||||
|
||||
public function getReport(): ReportDto;
|
||||
}
|
||||
37
app/Service/Import/Importers/ImporterProvider.php
Normal file
37
app/Service/Import/Importers/ImporterProvider.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ImporterProvider
|
||||
{
|
||||
private array $importers = [
|
||||
'toggl_time_entries' => TogglTimeEntriesImporter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param class-string<ImporterContract> $importer
|
||||
*/
|
||||
public function registerImporter(string $type, string $importer): void
|
||||
{
|
||||
$this->importers[$type] = $importer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getImporterKeys(): array
|
||||
{
|
||||
return array_keys($this->importers);
|
||||
}
|
||||
|
||||
public function getImporter(string $type): ImporterContract
|
||||
{
|
||||
if (! array_key_exists($type, $this->importers)) {
|
||||
throw new \InvalidArgumentException('Invalid importer type');
|
||||
}
|
||||
|
||||
return new $this->importers[$type];
|
||||
}
|
||||
}
|
||||
76
app/Service/Import/Importers/ReportDto.php
Normal file
76
app/Service/Import/Importers/ReportDto.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
class ReportDto
|
||||
{
|
||||
public int $clientsCreated;
|
||||
|
||||
public int $projectsCreated;
|
||||
|
||||
public int $tasksCreated;
|
||||
|
||||
public int $timeEntriesCreated;
|
||||
|
||||
public int $tagsCreated;
|
||||
|
||||
public int $usersCreated;
|
||||
|
||||
public function __construct(int $clientsCreated, int $projectsCreated, int $tasksCreated, int $timeEntriesCreated, int $tagsCreated, int $usersCreated)
|
||||
{
|
||||
$this->clientsCreated = $clientsCreated;
|
||||
$this->projectsCreated = $projectsCreated;
|
||||
$this->tasksCreated = $tasksCreated;
|
||||
$this->timeEntriesCreated = $timeEntriesCreated;
|
||||
$this->tagsCreated = $tagsCreated;
|
||||
$this->usersCreated = $usersCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* clients: array{
|
||||
* created: int,
|
||||
* },
|
||||
* projects: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tasks: array{
|
||||
* created: int,
|
||||
* },
|
||||
* time-entries: array{
|
||||
* created: int,
|
||||
* },
|
||||
* tags: array{
|
||||
* created: int,
|
||||
* },
|
||||
* users: array{
|
||||
* created: int,
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'clients' => [
|
||||
'created' => $this->clientsCreated,
|
||||
],
|
||||
'projects' => [
|
||||
'created' => $this->projectsCreated,
|
||||
],
|
||||
'tasks' => [
|
||||
'created' => $this->tasksCreated,
|
||||
],
|
||||
'time-entries' => [
|
||||
'created' => $this->timeEntriesCreated,
|
||||
],
|
||||
'tags' => [
|
||||
'created' => $this->tagsCreated,
|
||||
],
|
||||
'users' => [
|
||||
'created' => $this->usersCreated,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
189
app/Service/Import/Importers/TogglTimeEntriesImporter.php
Normal file
189
app/Service/Import/Importers/TogglTimeEntriesImporter.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\ColorService;
|
||||
use App\Service\Import\ImportDatabaseHelper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use League\Csv\Exception as CsvException;
|
||||
use League\Csv\Reader;
|
||||
|
||||
class TogglTimeEntriesImporter implements ImporterContract
|
||||
{
|
||||
private Organization $organization;
|
||||
|
||||
private ImportDatabaseHelper $userImportHelper;
|
||||
|
||||
private ImportDatabaseHelper $projectImportHelper;
|
||||
|
||||
private ImportDatabaseHelper $tagImportHelper;
|
||||
|
||||
private ImportDatabaseHelper $clientImportHelper;
|
||||
|
||||
private ImportDatabaseHelper $taskImportHelper;
|
||||
|
||||
private int $timeEntriesCreated;
|
||||
|
||||
#[\Override]
|
||||
public function init(Organization $organization): void
|
||||
{
|
||||
$this->organization = $organization;
|
||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||
return $builder->whereHas('organizations', function (Builder $builder): Builder {
|
||||
/** @var Builder<Organization> $builder */
|
||||
return $builder->whereKey($this->organization->getKey());
|
||||
});
|
||||
}, function (User $user) {
|
||||
$user->organizations()->attach([$this->organization->id]);
|
||||
});
|
||||
// TODO: user special after import
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
});
|
||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
});
|
||||
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
});
|
||||
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
});
|
||||
$this->timeEntriesCreated = 0;
|
||||
}
|
||||
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = explode(', ', $tags);
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
if (strlen($tagParsed) > 255) {
|
||||
throw new ImportException('Tag is too long');
|
||||
}
|
||||
$tagId = $this->tagImportHelper->getKey([
|
||||
'name' => $tagParsed,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
public function importData(string $data, array $options): void
|
||||
{
|
||||
try {
|
||||
$colorService = app(ColorService::class);
|
||||
$reader = Reader::createFromString($data);
|
||||
$reader->setHeaderOffset(0);
|
||||
$reader->setDelimiter(',');
|
||||
$header = $reader->getHeader();
|
||||
$this->validateHeader($header);
|
||||
$records = $reader->getRecords();
|
||||
foreach ($records as $record) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $record['Email'],
|
||||
], [
|
||||
'name' => $record['User'],
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$clientId = null;
|
||||
if ($record['Client'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKey([
|
||||
'name' => $record['Client'],
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$projectId = null;
|
||||
if ($record['Project'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKey([
|
||||
'name' => $record['Project'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $colorService->getRandomColor(),
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($record['Task'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKey([
|
||||
'name' => $record['Task'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->id,
|
||||
]);
|
||||
}
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
$timeEntry->description = $record['Description'];
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||
$timeEntry->start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC');
|
||||
$timeEntry->end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC');
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
} catch (CsvException $exception) {
|
||||
throw new ImportException('Invalid CSV data');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function validateHeader(array $header): void
|
||||
{
|
||||
$requiredFields = [
|
||||
'User',
|
||||
'Email',
|
||||
'Client',
|
||||
'Project',
|
||||
'Task',
|
||||
'Description',
|
||||
'Billable',
|
||||
'Start date',
|
||||
'Start time',
|
||||
'End date',
|
||||
'End time',
|
||||
'Tags',
|
||||
];
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (! in_array($requiredField, $header, true)) {
|
||||
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getReport(): ReportDto
|
||||
{
|
||||
return new ReportDto(
|
||||
clientsCreated: $this->clientImportHelper->getCreatedCount(),
|
||||
projectsCreated: $this->projectImportHelper->getCreatedCount(),
|
||||
tasksCreated: $this->taskImportHelper->getCreatedCount(),
|
||||
timeEntriesCreated: $this->timeEntriesCreated,
|
||||
tagsCreated: $this->tagImportHelper->getCreatedCount(),
|
||||
usersCreated: $this->userImportHelper->getCreatedCount(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,6 @@ return [
|
||||
],
|
||||
|
||||
'ignore_paths' => [
|
||||
'livewire*',
|
||||
'nova-api*',
|
||||
'pulse*',
|
||||
],
|
||||
|
||||
@@ -16,13 +16,17 @@ return new class extends Migration
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('email');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->string('password')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->boolean('is_placeholder')->default(false);
|
||||
$table->foreignUuid('current_team_id')->nullable();
|
||||
$table->string('profile_photo_path', 2048)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->uniqueIndex('email')
|
||||
->where('is_placeholder = false');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ImportController;
|
||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||
use App\Http\Controllers\Api\V1\ProjectController;
|
||||
use App\Http\Controllers\Api\V1\TagController;
|
||||
@@ -60,6 +61,11 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
||||
Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Import routes
|
||||
Route::name('import.')->group(static function () {
|
||||
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
1
storage/tests/clockify_import_test_1.csv
Normal file
1
storage/tests/clockify_import_test_1.csv
Normal file
@@ -0,0 +1 @@
|
||||
"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
|
||||
|
3
storage/tests/toggl_import_test_1.csv
Normal file
3
storage/tests/toggl_import_test_1.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)
|
||||
Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,Development,
|
||||
Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11
|
||||
|
58
tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php
Normal file
58
tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\ImportService;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_import_fails_if_user_does_not_have_permission()
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
]);
|
||||
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
'options' => [],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_import_calls_import_service_if_user_has_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission([
|
||||
'import',
|
||||
]);
|
||||
$this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void {
|
||||
$mock->shouldReceive('import')
|
||||
->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool {
|
||||
return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === [];
|
||||
})
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
'options' => [],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
71
tests/Unit/Service/Import/ImportDatabaseHelperTest.php
Normal file
71
tests/Unit/Service/Import/ImportDatabaseHelperTest.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Import;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Service\Import\ImportDatabaseHelper;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImportDatabaseHelperTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_get_key_attach_to_existing_returns_key_for_identifier_without_creating_model(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$helper = new ImportDatabaseHelper(User::class, ['email'], true);
|
||||
|
||||
// Act
|
||||
$key = $helper->getKey([
|
||||
'email' => $user->email,
|
||||
], [
|
||||
'name' => 'Test',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertSame($user->getKey(), $key);
|
||||
}
|
||||
|
||||
public function test_get_key_attach_to_existing_creates_model_if_not_existing(): void
|
||||
{
|
||||
// Arrange
|
||||
$helper = new ImportDatabaseHelper(User::class, ['email'], true);
|
||||
|
||||
// Act
|
||||
$key = $helper->getKey([
|
||||
'email' => 'test@mail.test',
|
||||
], [
|
||||
'name' => 'Test',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($key);
|
||||
$this->assertDatabaseHas(User::class, [
|
||||
'email' => 'test@mail.test',
|
||||
'name' => 'Test',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void
|
||||
{
|
||||
// Arrange
|
||||
$project = Project::factory()->create();
|
||||
$helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false);
|
||||
|
||||
// Act
|
||||
$key = $helper->getKey([
|
||||
'name' => $project->name,
|
||||
'organization_id' => $project->organization_id,
|
||||
], [
|
||||
'color' => '#000000',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertNotSame($project->getKey(), $key);
|
||||
}
|
||||
}
|
||||
53
tests/Unit/Service/Import/Importer/ImporterTestAbstract.php
Normal file
53
tests/Unit/Service/Import/Importer/ImporterTestAbstract.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Import\Importer;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImporterTestAbstract extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag}
|
||||
*/
|
||||
protected function checkTestScenarioAfterImportExcludingTimeEntries(): object
|
||||
{
|
||||
$users = User::all();
|
||||
$this->assertCount(2, $users);
|
||||
$user1 = $users->firstWhere('name', 'Peter Tester');
|
||||
$this->assertNotNull($user1);
|
||||
$this->assertSame(null, $user1->password);
|
||||
$this->assertSame('Peter Tester', $user1->name);
|
||||
$this->assertSame('peter.test@email.test', $user1->email);
|
||||
$projects = Project::all();
|
||||
$this->assertCount(2, $projects);
|
||||
$project1 = $projects->firstWhere('name', 'Project without Client');
|
||||
$this->assertNotNull($project1);
|
||||
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
||||
$this->assertNotNull($project2);
|
||||
$tasks = Task::all();
|
||||
$this->assertCount(1, $tasks);
|
||||
$task1 = $tasks->firstWhere('name', 'Task 1');
|
||||
$this->assertNotNull($task1);
|
||||
$this->assertSame($project2->getKey(), $task1->project_id);
|
||||
$tags = Tag::all();
|
||||
$this->assertCount(1, $tags);
|
||||
$tag1 = $tags->firstWhere('name', 'Development');
|
||||
$this->assertNotNull($tag1);
|
||||
|
||||
return (object) [
|
||||
'user1' => $user1,
|
||||
'project1' => $project1,
|
||||
'project2' => $project2,
|
||||
'tag1' => $tag1,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Import\Importer;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Import\Importers\TogglTimeEntriesImporter;
|
||||
|
||||
class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
{
|
||||
public function test_import_of_test_file_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$importer = new TogglTimeEntriesImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents(storage_path('tests/toggl_import_test_1.csv'));
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
|
||||
// Assert
|
||||
$this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$importer = new TogglTimeEntriesImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents(storage_path('tests/toggl_import_test_1.csv'));
|
||||
$importer->importData($data, []);
|
||||
$importer = new TogglTimeEntriesImporter();
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, []);
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
$timeEntries = TimeEntry::all();
|
||||
$this->assertCount(4, $timeEntries);
|
||||
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||
$this->assertNotNull($timeEntry1);
|
||||
$this->assertSame('', $timeEntry1->description);
|
||||
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertSame([$testScenario->tag1->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user