Added import system

This commit is contained in:
Constantin Graf
2024-03-05 19:19:07 +01:00
parent e8912650c0
commit 0ed5d14817
29 changed files with 946 additions and 4 deletions

View File

@@ -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([

View 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);
}
}
}

View File

@@ -13,7 +13,7 @@ class ValidateSignature extends Middleware
*
* @var array<int, string>
*/
protected $except = [
protected array $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',

View 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',
],
];
}
}

View File

@@ -25,6 +25,7 @@ class ProjectStoreRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',

View File

@@ -25,6 +25,7 @@ class ProjectUpdateRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'max:255',

View File

@@ -18,6 +18,7 @@ class TagStoreRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',

View File

@@ -18,6 +18,7 @@ class TagUpdateRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',

View File

@@ -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()

View File

@@ -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',
];
/**

View File

@@ -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');
}
}
}

View File

@@ -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', [

View 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)];
}
}

View 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;
}
}

View 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();
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
class ImportException extends \Exception
{
}

View 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;
}

View 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];
}
}

View 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,
],
];
}
}

View 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(),
);
}
}