Skip to content

Commit

Permalink
Add report exports
Browse files Browse the repository at this point in the history
  • Loading branch information
korridor committed Oct 16, 2024
1 parent 0290013 commit 22e064c
Show file tree
Hide file tree
Showing 12 changed files with 1,452 additions and 17 deletions.
35 changes: 35 additions & 0 deletions app/Enums/ExportFormat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Enums;

use Maatwebsite\Excel\Excel;

enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';

public function getFileExtension(): string

Check warning on line 16 in app/Enums/ExportFormat.php

View check run for this annotation

Codecov / codecov/patch

app/Enums/ExportFormat.php#L16

Added line #L16 was not covered by tests
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};

Check warning on line 23 in app/Enums/ExportFormat.php

View check run for this annotation

Codecov / codecov/patch

app/Enums/ExportFormat.php#L18-L23

Added lines #L18 - L23 were not covered by tests
}

public function getExportPackageType(): string

Check warning on line 26 in app/Enums/ExportFormat.php

View check run for this annotation

Codecov / codecov/patch

app/Enums/ExportFormat.php#L26

Added line #L26 was not covered by tests
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};

Check warning on line 33 in app/Enums/ExportFormat.php

View check run for this annotation

Codecov / codecov/patch

app/Enums/ExportFormat.php#L28-L33

Added lines #L28 - L33 were not covered by tests
}
}
93 changes: 78 additions & 15 deletions app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace App\Http\Controllers\Api\V1;

use App\Enums\ExportFormat;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
Expand All @@ -21,15 +23,20 @@
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;

class TimeEntryController extends Controller
{
Expand Down Expand Up @@ -63,21 +70,7 @@ public function index(Organization $organization, TimeEntryIndexRequest $request
$this->checkPermission($organization, 'time-entries:view:all');
}

$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');

$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);

$totalCount = $timeEntriesQuery->count();

Expand Down Expand Up @@ -128,6 +121,76 @@ public function index(Organization $organization, TimeEntryIndexRequest $request
]);
}

/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');

$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));

return $filter->get();
}

/**
* @throws AuthorizationException
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse

Check warning on line 151 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L151

Added line #L151 was not covered by tests
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');

Check warning on line 156 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L154-L156

Added lines #L154 - L156 were not covered by tests
} else {
$this->checkPermission($organization, 'time-entries:view:all');

Check warning on line 158 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L158

Added line #L158 was not covered by tests
}

$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'project' => [
'client',
],
'user',
'tagsRelation',
]);
$format = $request->getFormatValue();
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$path = 'exports/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $filename, $timeEntriesQuery, 1000);
$export->export();

Check warning on line 175 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L161-L175

Added lines #L161 - L175 were not covered by tests
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery),
$path,
config('filesystems.private'),
$format->getExportPackageType(),

Check warning on line 181 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L177-L181

Added lines #L177 - L181 were not covered by tests
[
'visibility' => 'private',

Check warning on line 183 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L183

Added line #L183 was not covered by tests
]
);
}

return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);

Check warning on line 191 in app/Http/Controllers/Api/V1/TimeEntryController.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Controllers/Api/V1/TimeEntryController.php#L188-L191

Added lines #L188 - L191 were not covered by tests
}

/**
* Get aggregated time entries in organization
*
Expand Down
143 changes: 143 additions & 0 deletions app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\V1\TimeEntry;

use App\Enums\ExportFormat;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;

/**
* @property Organization $organization
*/
class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array

Check warning on line 28 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L28

Added line #L28 was not covered by tests
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],

Check warning on line 35 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L30-L35

Added lines #L30 - L35 were not covered by tests
// Filter by member ID
'member_id' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {

Check warning on line 40 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L37-L40

Added lines #L37 - L40 were not covered by tests
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],

Check warning on line 44 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L42-L44

Added lines #L42 - L44 were not covered by tests
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {

Check warning on line 53 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L46-L53

Added lines #L46 - L53 were not covered by tests
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],

Check warning on line 57 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L55-L57

Added lines #L55 - L57 were not covered by tests
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {

Check warning on line 66 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L59-L66

Added lines #L59 - L66 were not covered by tests
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],

Check warning on line 70 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L68-L70

Added lines #L68 - L70 were not covered by tests
// Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {

Check warning on line 79 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L72-L79

Added lines #L72 - L79 were not covered by tests
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],

Check warning on line 83 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L81-L83

Added lines #L81 - L83 were not covered by tests
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {

Check warning on line 92 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L85-L92

Added lines #L85 - L92 were not covered by tests
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],

Check warning on line 96 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L94-L96

Added lines #L94 - L96 were not covered by tests
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],

Check warning on line 103 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L98-L103

Added lines #L98 - L103 were not covered by tests
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],

Check warning on line 109 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L105-L109

Added lines #L105 - L109 were not covered by tests
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],

Check warning on line 114 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L111-L114

Added lines #L111 - L114 were not covered by tests
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],

Check warning on line 119 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L116-L119

Added lines #L116 - L119 were not covered by tests
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
'min:1',
'max:500',
],

Check warning on line 125 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L121-L125

Added lines #L121 - L125 were not covered by tests
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
'in:true,false',
],
];

Check warning on line 131 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L127-L131

Added lines #L127 - L131 were not covered by tests
}

public function getOnlyFullDates(): bool

Check warning on line 134 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L134

Added line #L134 was not covered by tests
{
return $this->input('only_full_dates', 'false') === 'true';

Check warning on line 136 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L136

Added line #L136 was not covered by tests
}

public function getFormatValue(): ExportFormat

Check warning on line 139 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L139

Added line #L139 was not covered by tests
{
return ExportFormat::from($this->validated('format'));

Check warning on line 141 in app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php

View check run for this annotation

Codecov / codecov/patch

app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php#L141

Added line #L141 was not covered by tests
}
}
13 changes: 13 additions & 0 deletions app/Models/Tag.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;

/**
* @property string $id
Expand All @@ -30,6 +32,7 @@ class Tag extends Model implements AuditableContract
/** @use HasFactory<TagFactory> */
use HasFactory;

use HasJsonRelationships;
use HasUuids;

/**
Expand All @@ -48,4 +51,14 @@ public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}

/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return HasManyJson<TimeEntry, $this>
*/
public function timeEntries(): HasManyJson

Check warning on line 60 in app/Models/Tag.php

View check run for this annotation

Codecov / codecov/patch

app/Models/Tag.php#L60

Added line #L60 was not covered by tests
{
return $this->hasManyJson(TimeEntry::class, 'tags');

Check warning on line 62 in app/Models/Tag.php

View check run for this annotation

Codecov / codecov/patch

app/Models/Tag.php#L62

Added line #L62 was not covered by tests
}
}
17 changes: 16 additions & 1 deletion app/Models/TimeEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@
use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;

/**
* @property string $id
Expand All @@ -42,6 +45,7 @@
* @property-read Client|null $client
* @property string|null $task_id
* @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
Expand All @@ -50,10 +54,11 @@ class TimeEntry extends Model implements AuditableContract
{
use ComputedAttributes;
use CustomAuditable;

/** @use HasFactory<TimeEntryFactory> */
use HasFactory;

use HasJsonRelationships;

use HasUuids;

/**
Expand Down Expand Up @@ -197,4 +202,14 @@ public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id');
}

/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return BelongsToJson<Tag, $this>
*/
public function tagsRelation(): BelongsToJson

Check warning on line 211 in app/Models/TimeEntry.php

View check run for this annotation

Codecov / codecov/patch

app/Models/TimeEntry.php#L211

Added line #L211 was not covered by tests
{
return $this->belongsToJson(Tag::class, 'tags');

Check warning on line 213 in app/Models/TimeEntry.php

View check run for this annotation

Codecov / codecov/patch

app/Models/TimeEntry.php#L213

Added line #L213 was not covered by tests
}
}
Loading

0 comments on commit 22e064c

Please sign in to comment.