mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
Added export endpoint and solidtime import; Enhanced toggl import
This commit is contained in:
committed by
Constantin Graf
parent
056a63e193
commit
ee77de04ef
@@ -7,6 +7,7 @@ namespace App\Service\Import\Importers;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Tag;
|
||||
@@ -63,6 +64,11 @@ abstract class DefaultImporter implements ImporterContract
|
||||
*/
|
||||
protected ImportDatabaseHelper $projectMemberImportHelper;
|
||||
|
||||
/**
|
||||
* @var ImportDatabaseHelper<OrganizationInvitation>
|
||||
*/
|
||||
protected ImportDatabaseHelper $organizationInvitationsImportHelper;
|
||||
|
||||
protected BillableRateService $billableRateService;
|
||||
|
||||
public function init(Organization $organization): void
|
||||
@@ -149,6 +155,15 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'max:500',
|
||||
],
|
||||
]);
|
||||
$this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) {
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
}, validate: [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
],
|
||||
]);
|
||||
$this->timeEntriesCreated = 0;
|
||||
$this->colorService = app(ColorService::class);
|
||||
$this->timezoneService = app(TimezoneService::class);
|
||||
|
||||
@@ -14,6 +14,7 @@ class ImporterProvider
|
||||
'toggl_data_importer' => TogglDataImporter::class,
|
||||
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
|
||||
'clockify_projects' => ClockifyProjectsImporter::class,
|
||||
'solidtime' => SolidtimeImporter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
335
app/Service/Import/Importers/SolidtimeImporter.php
Normal file
335
app/Service/Import/Importers/SolidtimeImporter.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\TimeEntry;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\Reader;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class SolidtimeImporter extends DefaultImporter
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
public const array SUPPORTED_VERSIONS = ['1.0'];
|
||||
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
$zip->extractTo($temporaryDirectory->path());
|
||||
$zip->close();
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('meta.json'))) {
|
||||
throw new ImportException('File "meta.json" missing in ZIP');
|
||||
}
|
||||
$metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json'));
|
||||
if ($metaFileContentRaw === false) {
|
||||
throw new ImportException('File "meta.json" can not read');
|
||||
}
|
||||
$metaFileContent = json_decode($metaFileContentRaw);
|
||||
if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) {
|
||||
throw new ImportException('Invalid version');
|
||||
}
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('clients.csv'))) {
|
||||
throw new ImportException('File "clients.csv" missing in ZIP');
|
||||
}
|
||||
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
|
||||
$clientsReader->setHeaderOffset(0);
|
||||
$clientsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('members.csv'))) {
|
||||
throw new ImportException('File "members.csv" missing in ZIP');
|
||||
}
|
||||
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
|
||||
$membersReader->setHeaderOffset(0);
|
||||
$membersReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
|
||||
throw new ImportException('File "organization_invitations.csv" missing in ZIP');
|
||||
}
|
||||
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
|
||||
$organizationInvitationsReader->setHeaderOffset(0);
|
||||
$organizationInvitationsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
|
||||
throw new ImportException('File "project_members.csv" missing in ZIP');
|
||||
}
|
||||
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
|
||||
$projectMembersReader->setHeaderOffset(0);
|
||||
$projectMembersReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('projects.csv'))) {
|
||||
throw new ImportException('File "projects.csv" missing in ZIP');
|
||||
}
|
||||
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
|
||||
$projectsReader->setHeaderOffset(0);
|
||||
$projectsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tags.csv'))) {
|
||||
throw new ImportException('File "tags.csv" missing in ZIP');
|
||||
}
|
||||
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
|
||||
$tagsReader->setHeaderOffset(0);
|
||||
$tagsReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
|
||||
throw new ImportException('File "tasks.csv" missing in ZIP');
|
||||
}
|
||||
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
|
||||
$tasksReader->setHeaderOffset(0);
|
||||
$tasksReader->setDelimiter(',');
|
||||
|
||||
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
|
||||
throw new ImportException('File "time_entries.csv" missing in ZIP');
|
||||
}
|
||||
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
|
||||
$timeEntriesReader->setHeaderOffset(0);
|
||||
$timeEntriesReader->setDelimiter(',');
|
||||
|
||||
foreach ($clientsReader as $client) {
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $client['name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [
|
||||
'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $client['archived_at'], 'UTC') : null,
|
||||
], $client['id']);
|
||||
}
|
||||
|
||||
foreach ($tagsReader as $tag) {
|
||||
$this->tagImportHelper->getKey([
|
||||
'name' => $tag['name'],
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], $tag['id']);
|
||||
}
|
||||
|
||||
foreach ($membersReader as $member) {
|
||||
$userId = $this->userImportHelper->getKey([
|
||||
'email' => $member['email'],
|
||||
], [
|
||||
'name' => $member['name'],
|
||||
'timezone' => 'UTC',
|
||||
'is_placeholder' => true,
|
||||
], $member['user_id']);
|
||||
$this->memberImportHelper->getKey([
|
||||
'user_id' => $userId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'role' => Role::Placeholder->value,
|
||||
'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'],
|
||||
], $member['id']);
|
||||
}
|
||||
|
||||
foreach ($projectsReader as $project) {
|
||||
$clientId = null;
|
||||
if ($project['client_id'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($project['client_id']);
|
||||
if ($clientId === null) {
|
||||
throw new Exception('Client does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->colorService->isValid($project['color'])) {
|
||||
throw new ImportException('Invalid color');
|
||||
}
|
||||
|
||||
$this->projectImportHelper->getKey([
|
||||
'name' => $project['name'],
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'color' => $project['color'],
|
||||
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
|
||||
'is_public' => $project['is_public'] === 'true',
|
||||
'client_id' => $clientId,
|
||||
'is_billable' => $project['is_billable'] === 'true',
|
||||
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
|
||||
], $project['id']);
|
||||
}
|
||||
|
||||
foreach ($projectMembersReader as $projectMember) {
|
||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']);
|
||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']);
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']);
|
||||
$this->projectMemberImportHelper->getKey([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'],
|
||||
], $projectMember['id']);
|
||||
}
|
||||
|
||||
foreach ($tasksReader as $task) {
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']);
|
||||
if ($projectId === null) {
|
||||
throw new Exception('Project does not exist');
|
||||
}
|
||||
$this->taskImportHelper->getKey([
|
||||
'name' => $task['name'],
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [
|
||||
'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $task['done_at'], 'UTC') : null,
|
||||
], (string) $task['id']);
|
||||
}
|
||||
|
||||
// Time entries
|
||||
foreach ($timeEntriesReader as $timeEntryRow) {
|
||||
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
|
||||
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
|
||||
$member = $this->memberImportHelper->getModelById($memberId);
|
||||
$clientId = null;
|
||||
if ($timeEntryRow['client_id'] !== '') {
|
||||
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
|
||||
}
|
||||
$project = null;
|
||||
$projectId = null;
|
||||
$projectMember = null;
|
||||
if ($timeEntryRow['project_id'] !== '') {
|
||||
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
|
||||
$project = $this->projectImportHelper->getModelById($projectId);
|
||||
$projectMember = $this->projectMemberImportHelper->getModel([
|
||||
'project_id' => $projectId,
|
||||
'member_id' => $memberId,
|
||||
]);
|
||||
}
|
||||
$taskId = null;
|
||||
if ($timeEntryRow['task_id'] !== '') {
|
||||
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
|
||||
}
|
||||
$timeEntry = new TimeEntry();
|
||||
$timeEntry->user_id = $userId;
|
||||
$timeEntry->member_id = $memberId;
|
||||
$timeEntry->task_id = $taskId;
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($timeEntryRow['description']) > 500) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $timeEntryRow['description'];
|
||||
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
|
||||
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
try {
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['start'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
|
||||
}
|
||||
if ($start === null) {
|
||||
throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid');
|
||||
}
|
||||
$timeEntry->start = $start->utc();
|
||||
|
||||
if ($timeEntryRow['end'] !== '') {
|
||||
try {
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||
}
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
} else {
|
||||
$timeEntry->end = null;
|
||||
}
|
||||
|
||||
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
|
||||
try {
|
||||
$stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC');
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
|
||||
}
|
||||
if ($stillActiveEmailSentAt === null) {
|
||||
throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid');
|
||||
}
|
||||
$timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc();
|
||||
} else {
|
||||
$timeEntry->still_active_email_sent_at = null;
|
||||
}
|
||||
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
$project,
|
||||
$member,
|
||||
$this->organization
|
||||
);
|
||||
$timeEntry->save();
|
||||
$this->timeEntriesCreated++;
|
||||
}
|
||||
} catch (ImportException $exception) {
|
||||
throw $exception;
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
} finally {
|
||||
$temporaryDirectory?->delete();
|
||||
$temporaryDirectoryZip?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
private function getTags(string $tags): array
|
||||
{
|
||||
if (trim($tags) === '') {
|
||||
return [];
|
||||
}
|
||||
$tagsParsed = json_decode($tags);
|
||||
if ($tagsParsed === false || ! is_array($tagsParsed)) {
|
||||
return [];
|
||||
}
|
||||
$tagIds = [];
|
||||
foreach ($tagsParsed as $tagParsed) {
|
||||
if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) {
|
||||
continue;
|
||||
}
|
||||
$tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed);
|
||||
$tagIds[] = $tagId;
|
||||
}
|
||||
|
||||
return $tagIds;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.solidtime_importer.name');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.solidtime_importer.description');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Service\Import\Importers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use Exception;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Override;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ValueError;
|
||||
use ZipArchive;
|
||||
@@ -15,14 +17,16 @@ class TogglDataImporter extends DefaultImporter
|
||||
/**
|
||||
* @throws ImportException
|
||||
*/
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function importData(string $data, string $timezone): void
|
||||
{
|
||||
$temporaryDirectoryZip = null;
|
||||
$temporaryDirectory = null;
|
||||
try {
|
||||
$zip = new ZipArchive();
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectory->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
|
||||
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
|
||||
if ($res !== true) {
|
||||
throw new ImportException('Invalid ZIP, error code: '.$res);
|
||||
}
|
||||
@@ -77,7 +81,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
$this->clientImportHelper->getKey([
|
||||
'name' => $client->name,
|
||||
'organization_id' => $this->organization->id,
|
||||
], [], (string) $client->id);
|
||||
], [
|
||||
'archived_at' => $client->archived === true ? Carbon::now() : null,
|
||||
], (string) $client->id);
|
||||
}
|
||||
foreach ($tags as $tag) {
|
||||
$this->tagImportHelper->getKey([
|
||||
@@ -121,7 +127,8 @@ class TogglDataImporter extends DefaultImporter
|
||||
], [
|
||||
'client_id' => $clientId,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->rate !== null,
|
||||
'is_billable' => $project->billable,
|
||||
'is_public' => ! $project->is_private,
|
||||
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
|
||||
], (string) $project->id);
|
||||
|
||||
@@ -170,7 +177,9 @@ class TogglDataImporter extends DefaultImporter
|
||||
'name' => $task->name,
|
||||
'project_id' => $projectId,
|
||||
'organization_id' => $this->organization->getKey(),
|
||||
], [], (string) $task->id);
|
||||
], [
|
||||
'done_at' => $task->active === false ? Carbon::now() : null,
|
||||
], (string) $task->id);
|
||||
}
|
||||
}
|
||||
} catch (ValueError $exception) {
|
||||
@@ -180,16 +189,19 @@ class TogglDataImporter extends DefaultImporter
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
throw new ImportException('Unknown error');
|
||||
} finally {
|
||||
$temporaryDirectory?->delete();
|
||||
$temporaryDirectoryZip?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return __('importer.toggl_data_importer.name');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
#[Override]
|
||||
public function getDescription(): string
|
||||
{
|
||||
return __('importer.toggl_data_importer.description');
|
||||
|
||||
Reference in New Issue
Block a user