mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +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\Filament\Resources;
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
@@ -110,6 +111,30 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Action::make('Export')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(function (Organization $record) {
|
||||
try {
|
||||
$file = app(ExportService::class)->export($record);
|
||||
Notification::make()
|
||||
->title('Export successful')
|
||||
->success()
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
return response()->streamDownload(function () use ($file) {
|
||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||
}, 'export.zip');
|
||||
} catch (\Exception $exception) {
|
||||
report($exception);
|
||||
Notification::make()
|
||||
->title('Export failed')
|
||||
->danger()
|
||||
->body('Message: '.$exception->getMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
|
||||
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportException;
|
||||
use App\Service\Export\ExportService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Export data of an organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ExportException
|
||||
*
|
||||
* @operationId exportOrganization
|
||||
*/
|
||||
public function export(Organization $organization, ExportService $exportService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'export');
|
||||
|
||||
$filepath = $exportService->export($organization);
|
||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'download_url' => $downloadUrl,
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Database\Factories\MemberFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,8 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $organization_id
|
||||
* @property string $user_id
|
||||
* @property string $created_at
|
||||
* @property string $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Organization $organization
|
||||
* @property-read User $user
|
||||
*
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\OrganizationInvitationFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
|
||||
@@ -16,6 +17,8 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
*
|
||||
* @method static OrganizationInvitationFactory factory()
|
||||
|
||||
@@ -22,6 +22,7 @@ use Illuminate\Support\Carbon;
|
||||
* @property string $organization_id
|
||||
* @property string $client_id
|
||||
* @property int|null $billable_rate
|
||||
* @property bool $is_public
|
||||
* @property bool $is_billable
|
||||
* @property-read bool $is_archived
|
||||
* @property Carbon|null $archived_at
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string $project_id Project ID
|
||||
* @property string $member_id Member ID
|
||||
* @property string $user_id User ID (legacy)
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Project $project
|
||||
* @property-read Member $member
|
||||
* @property-read User $user
|
||||
|
||||
@@ -42,6 +42,7 @@ class Task extends Model
|
||||
*/
|
||||
protected $casts = [
|
||||
'name' => 'string',
|
||||
'done_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,13 +20,15 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
||||
* @property string $description
|
||||
* @property Carbon $start
|
||||
* @property Carbon|null $end
|
||||
* @property int $billable_rate Billable rate per hour in cents
|
||||
* @property int|null $billable_rate Billable rate per hour in cents
|
||||
* @property bool $billable
|
||||
* @property array $tags
|
||||
* @property string $user_id
|
||||
* @property string $member_id
|
||||
* @property bool $is_imported
|
||||
* @property Carbon|null $still_active_email_sent_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Member $member
|
||||
* @property string $organization_id
|
||||
|
||||
@@ -114,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
@@ -159,6 +160,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
|
||||
12
app/Service/Export/ExportException.php
Normal file
12
app/Service/Export/ExportException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Export;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
|
||||
class ExportException extends ApiException
|
||||
{
|
||||
public const string KEY = 'export';
|
||||
}
|
||||
362
app/Service/Export/ExportService.php
Normal file
362
app/Service/Export/ExportService.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Export;
|
||||
|
||||
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;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Csv\CannotInsertRecord;
|
||||
use League\Csv\Exception as LeagueCsvException;
|
||||
use League\Csv\UnavailableStream;
|
||||
use League\Csv\Writer;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class ExportService
|
||||
{
|
||||
public const string VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @throws ExportException
|
||||
*/
|
||||
public function export(Organization $organization): string
|
||||
{
|
||||
$exportId = Str::uuid();
|
||||
$timeStamp = Carbon::now();
|
||||
$temporaryDirectory = TemporaryDirectory::make();
|
||||
Log::debug('Start exporting organization', [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'export_id' => $exportId,
|
||||
]);
|
||||
|
||||
// Organizations
|
||||
try {
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'billable_rate',
|
||||
'currency',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
$writer->insertOne([
|
||||
$organization->id,
|
||||
$organization->name,
|
||||
$organization->billable_rate ?? '',
|
||||
$organization->currency,
|
||||
$organization->created_at?->toIso8601ZuluString() ?? '',
|
||||
$organization->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
|
||||
// Organization invitations
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'email',
|
||||
'organization_id',
|
||||
'role',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
OrganizationInvitation::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void {
|
||||
$organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$organizationInvitation->id,
|
||||
$organizationInvitation->email,
|
||||
$organizationInvitation->organization_id,
|
||||
$organizationInvitation->role,
|
||||
$organizationInvitation->created_at?->toIso8601ZuluString() ?? '',
|
||||
$organizationInvitation->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Time entries
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'description',
|
||||
'start',
|
||||
'end',
|
||||
'billable_rate',
|
||||
'billable',
|
||||
'member_id',
|
||||
'user_id',
|
||||
'organization_id',
|
||||
'client_id',
|
||||
'project_id',
|
||||
'task_id',
|
||||
'tags',
|
||||
'is_imported',
|
||||
'still_active_email_sent_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $timeEntries) use (&$writer): void {
|
||||
$timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void {
|
||||
$tags = json_encode($timeEntry->tags);
|
||||
$writer->insertOne([
|
||||
$timeEntry->id,
|
||||
$timeEntry->description,
|
||||
$timeEntry->start->toIso8601ZuluString(),
|
||||
$timeEntry->end?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->billable_rate ?? '',
|
||||
$timeEntry->billable ? 'true' : 'false',
|
||||
$timeEntry->member_id,
|
||||
$timeEntry->user_id,
|
||||
$timeEntry->organization_id,
|
||||
$timeEntry->client_id ?? '',
|
||||
$timeEntry->project_id ?? '',
|
||||
$timeEntry->task_id ?? '',
|
||||
$tags === false ? '' : $tags,
|
||||
$timeEntry->is_imported ? 'true' : 'false',
|
||||
$timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->created_at?->toIso8601ZuluString() ?? '',
|
||||
$timeEntry->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Clients
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'organization_id',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $clients) use (&$writer): void {
|
||||
$clients->each(function (Client $client) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$client->id,
|
||||
$client->name,
|
||||
$client->organization_id,
|
||||
$client->archived_at ?? '',
|
||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Projects
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'color',
|
||||
'billable_rate',
|
||||
'is_public',
|
||||
'client_id',
|
||||
'organization_id',
|
||||
'is_billable',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Project::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $projects) use (&$writer): void {
|
||||
$projects->each(function (Project $project) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$project->id,
|
||||
$project->name,
|
||||
$project->color,
|
||||
$project->billable_rate ?? '',
|
||||
$project->is_public ? 'true' : 'false',
|
||||
$project->client_id ?? '',
|
||||
$project->organization_id,
|
||||
$project->is_billable ? 'true' : 'false',
|
||||
$project->archived_at?->toIso8601ZuluString() ?? '',
|
||||
$project->created_at?->toIso8601ZuluString() ?? '',
|
||||
$project->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Project members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'billable_rate',
|
||||
'project_id',
|
||||
'user_id',
|
||||
'member_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
ProjectMember::query()
|
||||
->whereBelongsToOrganization($organization)
|
||||
->chunk(1000, function (Collection $projectMembers) use (&$writer): void {
|
||||
$projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$projectMember->id,
|
||||
$projectMember->billable_rate ?? '',
|
||||
$projectMember->project_id,
|
||||
$projectMember->user_id,
|
||||
$projectMember->member_id,
|
||||
$projectMember->created_at?->toIso8601ZuluString() ?? '',
|
||||
$projectMember->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Members
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'user_id',
|
||||
'name',
|
||||
'email',
|
||||
'organization_id',
|
||||
'billable_rate',
|
||||
'role',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with([
|
||||
'user',
|
||||
])
|
||||
->chunk(1000, function (Collection $members) use (&$writer): void {
|
||||
$members->each(function (Member $member) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$member->id,
|
||||
$member->user_id,
|
||||
$member->user->name,
|
||||
$member->user->email,
|
||||
$member->organization_id,
|
||||
$member->billable_rate ?? '',
|
||||
$member->role,
|
||||
$member->created_at?->toIso8601ZuluString() ?? '',
|
||||
$member->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Tasks
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'project_id',
|
||||
'organization_id',
|
||||
'done_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Task::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $tasks) use (&$writer): void {
|
||||
$tasks->each(function (Task $task) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$task->id,
|
||||
$task->name,
|
||||
$task->project_id,
|
||||
$task->organization_id,
|
||||
$task->done_at?->toIso8601ZuluString() ?? '',
|
||||
$task->created_at?->toIso8601ZuluString() ?? '',
|
||||
$task->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Tags
|
||||
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
|
||||
$writer->insertOne([
|
||||
'id',
|
||||
'name',
|
||||
'organization_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
Tag::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->chunk(1000, function (Collection $tags) use (&$writer): void {
|
||||
$tags->each(function (Tag $tag) use (&$writer): void {
|
||||
$writer->insertOne([
|
||||
$tag->id,
|
||||
$tag->name,
|
||||
$tag->organization_id,
|
||||
$tag->created_at?->toIso8601ZuluString() ?? '',
|
||||
$tag->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Meta data file
|
||||
$metaData = (object) [
|
||||
'id' => $exportId,
|
||||
'version' => self::VERSION,
|
||||
'organizations' => [$organization->getKey()],
|
||||
'exported_at' => $timeStamp->toIso8601ZuluString(),
|
||||
];
|
||||
file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData));
|
||||
|
||||
// Create ZIP file
|
||||
$temporaryDirectoryZip = TemporaryDirectory::make();
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Cannot create ZIP file');
|
||||
}
|
||||
$zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv');
|
||||
$zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv');
|
||||
$zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv');
|
||||
$zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv');
|
||||
$zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv');
|
||||
$zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv');
|
||||
$zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv');
|
||||
$zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv');
|
||||
$zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv');
|
||||
$zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json');
|
||||
$zip->close();
|
||||
|
||||
// Upload ZIP file to private storage
|
||||
$filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip';
|
||||
Storage::disk(config('filesystems.private'))->putFileAs(
|
||||
'exports',
|
||||
new File($temporaryDirectoryZip->path('export.zip')),
|
||||
$filename
|
||||
);
|
||||
|
||||
// Delete temp files
|
||||
$temporaryDirectoryZip->delete();
|
||||
$temporaryDirectory->delete();
|
||||
|
||||
Log::debug('Finished exporting organization', [
|
||||
'organization_id' => $organization->getKey(),
|
||||
'export_id' => $exportId,
|
||||
]);
|
||||
|
||||
return 'exports/'.$filename;
|
||||
} catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) {
|
||||
report($exception);
|
||||
|
||||
throw new ExportException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -17,6 +17,10 @@ return [
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
'public' => env('PUBLIC_FILESYSTEM_DISK', 'public'),
|
||||
|
||||
'private' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Enums\Role;
|
||||
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;
|
||||
@@ -39,6 +40,9 @@ class DatabaseSeeder extends Seeder
|
||||
'personal_team' => false,
|
||||
'currency' => 'EUR',
|
||||
]);
|
||||
OrganizationInvitation::factory()->forOrganization($organizationAcme)->create([
|
||||
'email' => 'new.employee@example.com',
|
||||
]);
|
||||
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
|
||||
'name' => 'Acme Manager',
|
||||
'email' => 'test@example.com',
|
||||
@@ -62,6 +66,15 @@ class DatabaseSeeder extends Seeder
|
||||
$userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create();
|
||||
$userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create();
|
||||
$userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create();
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Code Review',
|
||||
]);
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Meeting',
|
||||
]);
|
||||
Tag::factory()->forOrganization($organizationAcme)->create([
|
||||
'name' => 'Research',
|
||||
]);
|
||||
|
||||
TimeEntry::factory()
|
||||
->count(10)
|
||||
@@ -147,6 +160,7 @@ class DatabaseSeeder extends Seeder
|
||||
DB::table((new Project())->getTable())->delete();
|
||||
DB::table((new Client())->getTable())->delete();
|
||||
DB::table((new User())->getTable())->delete();
|
||||
DB::table((new OrganizationInvitation())->getTable())->delete();
|
||||
DB::table((new Organization())->getTable())->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Service\Export\ExportException;
|
||||
|
||||
return [
|
||||
'api' => [
|
||||
@@ -29,6 +30,7 @@ return [
|
||||
OnlyOwnerCanChangeOwnership::KEY => 'Only owner can change ownership',
|
||||
OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',
|
||||
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
|
||||
ExportException::KEY => 'Export failed, please try again later or contact support',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -32,4 +32,8 @@ return [
|
||||
'<br><br>1. Go to Admin -> Settings -> Data export. <br>2. Under "Time entries" select the year you want to export and click on "Export time entries". <br><br>You can export all years one after another and import them one after another. '.
|
||||
' <br>Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.',
|
||||
],
|
||||
'solidtime_importer' => [
|
||||
'name' => 'Solidtime',
|
||||
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
|
||||
],
|
||||
];
|
||||
|
||||
3
resources/testfiles/solidtime_import_test_1/clients.csv
Normal file
3
resources/testfiles/solidtime_import_test_1/clients.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,organization_id,archived_at,created_at,updated_at
|
||||
b4187a44-41f4-46d7-8460-f15a25b3aad6,"Big Company",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,"Other Company (Archived)",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
2
resources/testfiles/solidtime_import_test_1/members.csv
Normal file
2
resources/testfiles/solidtime_import_test_1/members.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at
|
||||
06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,"Peter Tester",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
1
resources/testfiles/solidtime_import_test_1/meta.json
Normal file
1
resources/testfiles/solidtime_import_test_1/meta.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"d6a324ee-58d5-4096-8069-c63bd55608f7","version":"1.0","organizations":["ee5a8cd6-312f-4ae6-b044-e2014f09ecc2"],"exported_at":"2024-08-26T18:21:59Z"}
|
||||
@@ -0,0 +1 @@
|
||||
id,email,organization_id,role,created_at,updated_at
|
||||
|
@@ -0,0 +1,2 @@
|
||||
id,name,billable_rate,currency,created_at,updated_at
|
||||
ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,"ACME Corp",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
@@ -0,0 +1,2 @@
|
||||
id,billable_rate,project_id,user_id,member_id,created_at,updated_at
|
||||
180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
4
resources/testfiles/solidtime_import_test_1/projects.csv
Normal file
4
resources/testfiles/solidtime_import_test_1/projects.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at
|
||||
06e79ec4-33f8-4730-804c-d03c014991d1,"Project for Big Company",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
622c74a9-7e64-44c2-9426-2a37ac738206,"Project without Client",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
aa831162-dbb2-4cfe-bfe0-5e3a252c66f0,"Project (Archived)",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
3
resources/testfiles/solidtime_import_test_1/tags.csv
Normal file
3
resources/testfiles/solidtime_import_test_1/tags.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,organization_id,created_at,updated_at
|
||||
2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
bf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
3
resources/testfiles/solidtime_import_test_1/tasks.csv
Normal file
3
resources/testfiles/solidtime_import_test_1/tasks.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,project_id,organization_id,done_at,created_at,updated_at
|
||||
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 1",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 2",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
@@ -0,0 +1,3 @@
|
||||
id,description,start,end,billable_rate,billable,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at
|
||||
00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,"[""2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7"",""bf6c0ac5-2587-474b-8983-40bb3ea8002f""]",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
1c7a905d-aa12-4d08-bc41-7e92577e7cdf,"Working hard",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
|
||||
|
@@ -5,5 +5,12 @@
|
||||
"id": 301,
|
||||
"name": "Big Company",
|
||||
"wid": 0
|
||||
},
|
||||
{
|
||||
"archived": true,
|
||||
"creator_id": 201,
|
||||
"id": 302,
|
||||
"name": "Other Company (Archived)",
|
||||
"wid": 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"actual_hours": null,
|
||||
"actual_seconds": null,
|
||||
"auto_estimates": false,
|
||||
"billable": true,
|
||||
"billable": false,
|
||||
"cid": null,
|
||||
"client_id": null,
|
||||
"color": "#ef5350",
|
||||
@@ -32,7 +32,7 @@
|
||||
"actual_hours": null,
|
||||
"actual_seconds": null,
|
||||
"auto_estimates": false,
|
||||
"billable": false,
|
||||
"billable": true,
|
||||
"cid": 301,
|
||||
"client_id": 301,
|
||||
"color": "#ec407a",
|
||||
@@ -54,5 +54,33 @@
|
||||
"template_id": null,
|
||||
"wid": 0,
|
||||
"workspace_id": 0
|
||||
},
|
||||
{
|
||||
"active": false,
|
||||
"actual_hours": null,
|
||||
"actual_seconds": null,
|
||||
"auto_estimates": false,
|
||||
"billable": true,
|
||||
"cid": 302,
|
||||
"client_id": 302,
|
||||
"color": "#6a407f",
|
||||
"currency": null,
|
||||
"estimated_hours": null,
|
||||
"estimated_seconds": null,
|
||||
"fixed_fee": null,
|
||||
"guid": "",
|
||||
"id": 403,
|
||||
"is_private": false,
|
||||
"name": "Project (Archived)",
|
||||
"rate": null,
|
||||
"rate_last_updated": null,
|
||||
"recurring": false,
|
||||
"recurring_parameters": null,
|
||||
"start_date": "2020-01-01",
|
||||
"status": "active",
|
||||
"template": false,
|
||||
"template_id": null,
|
||||
"wid": 0,
|
||||
"workspace_id": 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -9,5 +9,16 @@
|
||||
"tracked_seconds": 0,
|
||||
"user_id": null,
|
||||
"workspace_id": 0
|
||||
},
|
||||
{
|
||||
"active": false,
|
||||
"estimated_seconds": 0,
|
||||
"id": 602,
|
||||
"name": "Task 2",
|
||||
"project_id": 403,
|
||||
"recurring": false,
|
||||
"tracked_seconds": 0,
|
||||
"user_id": null,
|
||||
"workspace_id": 0
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\ClientController;
|
||||
use App\Http\Controllers\Api\V1\ExportController;
|
||||
use App\Http\Controllers\Api\V1\ImportController;
|
||||
use App\Http\Controllers\Api\V1\InvitationController;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
@@ -125,6 +126,11 @@ Route::middleware([
|
||||
Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import');
|
||||
});
|
||||
|
||||
// Export routes
|
||||
Route::name('export.')->prefix('/organizations/{organization}')->group(static function () {
|
||||
Route::post('/export', [ExportController::class, 'export'])->name('export');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
93
tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php
Normal file
93
tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ExportController;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportException;
|
||||
use App\Service\Export\ExportService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[UsesClass(ExportController::class)]
|
||||
class ExportEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_export_fails_if_user_does_not_have_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
$this->mock(ExportService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldNotReceive('export');
|
||||
});
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.export.export', ['organization' => $data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_export_return_error_message_if_export_fails(): void
|
||||
{
|
||||
$user = $this->createUserWithPermission([
|
||||
'export',
|
||||
]);
|
||||
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user): void {
|
||||
$mock->shouldReceive('export')
|
||||
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||
return $organization->is($user->organization);
|
||||
})
|
||||
->andThrow(new ExportException())
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.export.export', ['organization' => $user->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertExactJson([
|
||||
'error' => true,
|
||||
'key' => 'export',
|
||||
'message' => 'Export failed, please try again later or contact support',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_export_calls_export_service_if_user_has_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission([
|
||||
'export',
|
||||
]);
|
||||
$filepath = 'exports/path.zip';
|
||||
Storage::fake('local');
|
||||
$now = Carbon::now();
|
||||
$this->travelTo($now);
|
||||
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
|
||||
$mock->shouldReceive('export')
|
||||
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||
return $organization->is($user->organization);
|
||||
})
|
||||
->andReturn($filepath)
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.export.export', ['organization' => $user->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertExactJson([
|
||||
'success' => true,
|
||||
'download_url' => Storage::disk('local')->temporaryUrl($filepath, $now->addMinutes(10)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
tests/Unit/Service/Export/ExportServiceTest.php
Normal file
60
tests/Unit/Service/Export/ExportServiceTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Export;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\Export\ExportService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(ExportService::class)]
|
||||
#[UsesClass(ExportService::class)]
|
||||
class ExportServiceTest extends TestCaseWithDatabase
|
||||
{
|
||||
private function getFullOrganization(): Organization
|
||||
{
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$organization = Organization::factory()->withOwner($user1)->create();
|
||||
$member1 = Member::factory()->forUser($user1)->forOrganization($organization)->create();
|
||||
$member2 = Member::factory()->forUser($user2)->forOrganization($organization)->create();
|
||||
$timeEntry1 = TimeEntry::factory()->forMember($member1)->create();
|
||||
$timeEntry2 = TimeEntry::factory()->forMember($member1)->create();
|
||||
$project1 = Project::factory()->forOrganization($organization)->create();
|
||||
$project2 = Project::factory()->forOrganization($organization)->create();
|
||||
$task1 = Task::factory()->forOrganization($organization)->forProject($project1)->create();
|
||||
$task2 = Task::factory()->forOrganization($organization)->forProject($project1)->create();
|
||||
$task3 = Task::factory()->forOrganization($organization)->forProject($project2)->create();
|
||||
$projectMember1 = ProjectMember::factory()->forMember($member1)->forProject($project1)->create();
|
||||
$projectMember2 = ProjectMember::factory()->forMember($member2)->forProject($project1)->create();
|
||||
$client1 = Client::factory()->forOrganization($organization)->create();
|
||||
$client2 = Client::factory()->forOrganization($organization)->create();
|
||||
|
||||
return $organization;
|
||||
}
|
||||
|
||||
public function test_export_creates_zip_with_all_the_data_of_the_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization1 = $this->getFullOrganization();
|
||||
$organization2 = $this->getFullOrganization();
|
||||
|
||||
// Act
|
||||
$exportService = app(ExportService::class);
|
||||
$zip = $exportService->export($organization1);
|
||||
|
||||
// Assert
|
||||
Storage::disk('local')->assertExists($zip);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Import\Importers\ClockifyTimeEntriesImporter;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
@@ -30,27 +29,17 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
$timeEntries = TimeEntry::all();
|
||||
$this->assertCount(2, $timeEntries);
|
||||
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||
$this->assertNotNull($timeEntry1);
|
||||
$this->assertSame('', $timeEntry1->description);
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertTrue($timeEntry1->is_imported);
|
||||
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->is_imported);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->checkTimeEntries($testScenario);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
@@ -67,26 +56,16 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// 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 09:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertTrue($timeEntry1->is_imported);
|
||||
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->is_imported);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->checkTimeEntries($testScenario, true);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(0, $report->tagsCreated);
|
||||
$this->assertSame(0, $report->tasksCreated);
|
||||
$this->assertSame(0, $report->usersCreated);
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class ImporterProviderTest extends TestCase
|
||||
'toggl_data_importer',
|
||||
'clockify_time_entries',
|
||||
'clockify_projects',
|
||||
'solidtime',
|
||||
], $keys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,14 @@ use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ImporterTestAbstract extends TestCase
|
||||
{
|
||||
@@ -36,29 +41,82 @@ class ImporterTestAbstract extends TestCase
|
||||
$this->assertNotNull($member1);
|
||||
$this->assertSame(Role::Placeholder->value, $member1->role);
|
||||
$clients = Client::all();
|
||||
$this->assertCount(1, $clients);
|
||||
$client1 = $clients->firstWhere('name', 'Big Company');
|
||||
$this->assertNotNull($client1);
|
||||
if ($detailed) {
|
||||
$this->assertCount(2, $clients);
|
||||
$client1 = $clients->firstWhere('name', 'Big Company');
|
||||
$this->assertNotNull($client1);
|
||||
$this->assertNull($client1->archived_at);
|
||||
$client2 = $clients->firstWhere('name', 'Other Company (Archived)');
|
||||
$this->assertNotNull($client2);
|
||||
$this->assertNotNull($client2->archived_at);
|
||||
} else {
|
||||
$this->assertCount(1, $clients);
|
||||
$client1 = $clients->firstWhere('name', 'Big Company');
|
||||
$this->assertNotNull($client1);
|
||||
$this->assertNull($client1->archived_at);
|
||||
}
|
||||
$projects = Project::with(['members'])->get();
|
||||
$this->assertCount(2, $projects);
|
||||
if ($detailed) {
|
||||
$this->assertCount(3, $projects);
|
||||
} else {
|
||||
$this->assertCount(2, $projects);
|
||||
}
|
||||
/** @var Project|null $project1 */
|
||||
$project1 = $projects->firstWhere('name', 'Project without Client');
|
||||
$this->assertNotNull($project1);
|
||||
$this->assertNull($project1->client_id);
|
||||
/** @var Project|null $project2 */
|
||||
$project2 = $projects->firstWhere('name', 'Project for Big Company');
|
||||
$this->assertNotNull($project2);
|
||||
$this->assertSame($client1->getKey(), $project2->client_id);
|
||||
$project3 = null;
|
||||
if ($detailed) {
|
||||
/** @var Project|null $project3 */
|
||||
$project3 = $projects->firstWhere('name', 'Project (Archived)');
|
||||
$this->assertNotNull($project3);
|
||||
// Project without Client
|
||||
$this->assertSame(false, $project1->is_billable);
|
||||
$this->assertSame(false, $project1->is_public);
|
||||
$this->assertSame('#ef5350', $project1->color);
|
||||
$this->assertSame(null, $project1->billable_rate);
|
||||
// Project for Big Company
|
||||
$this->assertSame(true, $project2->is_billable);
|
||||
$this->assertSame(false, $project2->is_public);
|
||||
$this->assertSame('#ec407a', $project2->color);
|
||||
$this->assertSame(10001, $project2->billable_rate);
|
||||
// Project (Archived)
|
||||
$this->assertSame(true, $project3->is_billable);
|
||||
$this->assertSame(true, $project3->is_public);
|
||||
$this->assertSame('#6a407f', $project3->color);
|
||||
$this->assertSame(null, $project3->billable_rate);
|
||||
$this->assertSame($client2->getKey(), $project3->client_id);
|
||||
// Project members
|
||||
$projectMembersOfProject2 = $project2->members;
|
||||
$this->assertCount(1, $projectMembersOfProject2);
|
||||
$this->assertSame($user1->getKey(), $projectMembersOfProject2->first()->user_id);
|
||||
$this->assertSame(10002, $projectMembersOfProject2->first()->billable_rate);
|
||||
} else {
|
||||
// Project without Client
|
||||
$this->assertSame(false, $project1->is_public);
|
||||
// Project for Big Company
|
||||
$this->assertSame(false, $project2->is_public);
|
||||
}
|
||||
$tasks = Task::all();
|
||||
$this->assertCount(1, $tasks);
|
||||
if ($detailed) {
|
||||
$this->assertCount(2, $tasks);
|
||||
} else {
|
||||
$this->assertCount(1, $tasks);
|
||||
}
|
||||
$task1 = $tasks->firstWhere('name', 'Task 1');
|
||||
$this->assertNotNull($task1);
|
||||
$this->assertNull($task1->done_at);
|
||||
$this->assertSame($project2->getKey(), $task1->project_id);
|
||||
if ($detailed) {
|
||||
$task2 = $tasks->firstWhere('name', 'Task 2');
|
||||
$this->assertNotNull($task1);
|
||||
$this->assertSame($project2->getKey(), $task2->project_id);
|
||||
$this->assertNotNull($task2->done_at);
|
||||
}
|
||||
$tags = Tag::all();
|
||||
$this->assertCount(2, $tags);
|
||||
$tag1 = $tags->firstWhere('name', 'Development');
|
||||
@@ -69,6 +127,7 @@ class ImporterTestAbstract extends TestCase
|
||||
'user1' => $user1,
|
||||
'project1' => $project1,
|
||||
'project2' => $project2,
|
||||
'project3' => $project3,
|
||||
'tag1' => $tag1,
|
||||
'tag2' => $tag2,
|
||||
];
|
||||
@@ -111,4 +170,47 @@ class ImporterTestAbstract extends TestCase
|
||||
'task1' => $task1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
|
||||
*/
|
||||
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
|
||||
{
|
||||
$timeEntries = TimeEntry::all();
|
||||
if ($secondRun) {
|
||||
$this->assertCount(4, $timeEntries);
|
||||
} else {
|
||||
$this->assertCount(2, $timeEntries);
|
||||
}
|
||||
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||
$this->assertNotNull($timeEntry1);
|
||||
$this->assertSame('', $timeEntry1->description);
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertTrue($timeEntry1->is_imported);
|
||||
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertTrue($timeEntry2->is_imported);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
}
|
||||
|
||||
protected function createTestZip(string $folder): string
|
||||
{
|
||||
$tempDir = TemporaryDirectory::make();
|
||||
$zipPath = $tempDir->path('test.zip');
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (Storage::disk('testfiles')->allFiles($folder) as $file) {
|
||||
$zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value());
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\SolidtimeImporter;
|
||||
use Exception;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(SolidtimeImporter::class)]
|
||||
#[CoversClass(ImportException::class)]
|
||||
#[CoversClass(DefaultImporter::class)]
|
||||
#[UsesClass(SolidtimeImporter::class)]
|
||||
class SolidtimeImporterTest extends ImporterTestAbstract
|
||||
{
|
||||
public function test_import_throws_exception_if_data_is_not_zip(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new SolidtimeImporter();
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData('not a zip', $timezone);
|
||||
} catch (Exception $e) {
|
||||
$this->assertInstanceOf(ImportException::class, $e);
|
||||
$this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
||||
$timezone = 'Europe/Vienna';
|
||||
$organization = Organization::factory()->create();
|
||||
$importer = new SolidtimeImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents($zipPath);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);
|
||||
$this->checkTimeEntries($testScenario);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(2, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(3, $report->projectsCreated);
|
||||
$this->assertSame(2, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
{
|
||||
// Arrange
|
||||
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
||||
$timezone = 'Europe/Vienna';
|
||||
$organization = Organization::factory()->create();
|
||||
$importer = new SolidtimeImporter();
|
||||
$importer->init($organization);
|
||||
$data = file_get_contents($zipPath);
|
||||
$importer->importData($data, $timezone);
|
||||
$importer = new SolidtimeImporter();
|
||||
$importer->init($organization);
|
||||
|
||||
// Act
|
||||
$importer->importData($data, $timezone);
|
||||
$report = $importer->getReport();
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);
|
||||
$this->checkTimeEntries($testScenario, true);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(0, $report->tagsCreated);
|
||||
$this->assertSame(0, $report->tasksCreated);
|
||||
$this->assertSame(0, $report->usersCreated);
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,8 @@ use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\TogglDataImporter;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
#[CoversClass(TogglDataImporter::class)]
|
||||
#[CoversClass(ImportException::class)]
|
||||
@@ -22,20 +18,6 @@ use ZipArchive;
|
||||
#[UsesClass(TogglDataImporter::class)]
|
||||
class TogglDataImporterTest extends ImporterTestAbstract
|
||||
{
|
||||
private function createTestZip(string $folder): string
|
||||
{
|
||||
$tempDir = TemporaryDirectory::make();
|
||||
$zipPath = $tempDir->path('test.zip');
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach (Storage::disk('testfiles')->allFiles($folder) as $file) {
|
||||
$zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value());
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
public function test_import_throws_exception_if_data_is_not_zip(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -74,10 +56,10 @@ class TogglDataImporterTest extends ImporterTestAbstract
|
||||
$this->checkTestScenarioAfterImportExcludingTimeEntries(true);
|
||||
$this->assertSame(0, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
$this->assertSame(2, $report->tasksCreated);
|
||||
$this->assertSame(1, $report->usersCreated);
|
||||
$this->assertSame(2, $report->projectsCreated);
|
||||
$this->assertSame(1, $report->clientsCreated);
|
||||
$this->assertSame(3, $report->projectsCreated);
|
||||
$this->assertSame(2, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_of_test_file_twice_succeeds(): void
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Service\Import\Importers;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Import\Importers\DefaultImporter;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\TogglTimeEntriesImporter;
|
||||
@@ -34,22 +33,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
|
||||
// Assert
|
||||
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
|
||||
$timeEntries = TimeEntry::all();
|
||||
$this->assertCount(2, $timeEntries);
|
||||
$timeEntry1 = $timeEntries->firstWhere('description', '');
|
||||
$this->assertNotNull($timeEntry1);
|
||||
$this->assertSame('', $timeEntry1->description);
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->checkTimeEntries($testScenario);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(2, $report->tagsCreated);
|
||||
$this->assertSame(1, $report->tasksCreated);
|
||||
@@ -76,22 +60,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
|
||||
// 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 09:23:52', $timeEntry1->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString());
|
||||
$this->assertFalse($timeEntry1->billable);
|
||||
$this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags);
|
||||
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
|
||||
$this->assertNotNull($timeEntry2);
|
||||
$this->assertSame('Working hard', $timeEntry2->description);
|
||||
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
|
||||
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
|
||||
$this->assertTrue($timeEntry2->billable);
|
||||
$this->assertSame([], $timeEntry2->tags);
|
||||
$this->checkTimeEntries($testScenario, true);
|
||||
$this->assertSame(2, $report->timeEntriesCreated);
|
||||
$this->assertSame(0, $report->tagsCreated);
|
||||
$this->assertSame(0, $report->tasksCreated);
|
||||
|
||||
Reference in New Issue
Block a user