API: Added user info and shifts by user and angeltype, simplified neededAngelTypes

This commit is contained in:
Igor Scheller 2023-12-14 00:54:58 +01:00 committed by Michael Weimann
parent 4de882ef85
commit 02f998fc38
7 changed files with 453 additions and 85 deletions

View File

@ -123,10 +123,16 @@ $route->addGroup(
$route->get('/openapi', 'Api\IndexController@openApiV0'); $route->get('/openapi', 'Api\IndexController@openApiV0');
$route->get('/angeltypes', 'Api\AngelTypeController@index'); $route->get('/angeltypes', 'Api\AngelTypeController@index');
$route->get('/news', 'Api\NewsController@index'); $route->get('/angeltypes/{angeltype_id:\d+}/shifts', 'Api\ShiftsController@entriesByAngeltype');
$route->get('/locations', 'Api\LocationsController@index'); $route->get('/locations', 'Api\LocationsController@index');
$route->get('/locations/{location_id:\d+}/shifts', 'Api\ShiftsController@entriesByLocation'); $route->get('/locations/{location_id:\d+}/shifts', 'Api\ShiftsController@entriesByLocation');
$route->get('/news', 'Api\NewsController@index');
$route->get('/users/self', 'Api\UsersController@self');
$route->get('/users/{user_id:\d+}/angeltypes', 'Api\AngelTypeController@ofUser'); $route->get('/users/{user_id:\d+}/angeltypes', 'Api\AngelTypeController@ofUser');
$route->get('/users/{user_id:\d+}/shifts', 'Api\ShiftsController@entriesByUser');
$route->addRoute( $route->addRoute(
['POST', 'PUT', 'DELETE', 'PATCH'], ['POST', 'PUT', 'DELETE', 'PATCH'],

View File

@ -21,10 +21,14 @@ servers:
description: Your local dev instance description: Your local dev instance
tags: tags:
- name: angeltype
description: Angeltypes
- name: location
description: Event locations
- name: news - name: news
description: News and meeting announcements description: News and meeting announcements
- name: shift - name: shift
description: Event shifts and location description: Event shifts
- name: user - name: user
description: User information description: User information
@ -321,6 +325,55 @@ components:
- pronoun - pronoun
- contact - contact
- url - url
UserDetail:
allOf:
- $ref: '#/components/schemas/User'
- type: object
properties:
email:
type: string
example: user@example.com
tshirt:
type: string
nullable: true
example: XL
dates:
type: object
properties:
planned_arrival:
type: string
nullable: true
format: date-time
description: DateTime in ISO-8601 format
example: 2023-05-13T00:00:00.000000Z
planned_departure:
type: string
nullable: true
format: date-time
description: DateTime in ISO-8601 format
example: 2023-05-23T00:00:00.000000Z
arrival:
type: string
nullable: true
format: date-time
description: Actual arrival date, DateTime in ISO-8601 format
example: 2023-05-23T13:37:42.000000Z
required:
- planned_arrival
- planned_departure
- arrival
language:
type: string
example: en_US
arrived:
type: boolean
example: true
required:
- email
- tshirt
- dates
- language
- arrived
security: security:
- bearerAuth: [ ] - bearerAuth: [ ]
@ -329,7 +382,7 @@ paths:
/angeltypes: /angeltypes:
get: get:
tags: tags:
- shift - angeltype
summary: Get a list of angeltypes summary: Get a list of angeltypes
responses: responses:
'200': '200':
@ -348,6 +401,33 @@ paths:
'403': '403':
$ref: '#/components/responses/ForbiddenError' $ref: '#/components/responses/ForbiddenError'
/angeltypes/{id}/shifts:
parameters:
- name: id
in: path
required: true
description: The angeltype identifier
example: 42
schema:
type: integer
get:
tags:
- angeltype
- shift
summary: Get all shifts of the requested angeltype
responses:
'200':
description: Ok
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Shift'
/news: /news:
get: get:
tags: tags:
@ -373,7 +453,7 @@ paths:
/locations: /locations:
get: get:
tags: tags:
- shift - location
summary: Get a list of locations summary: Get a list of locations
responses: responses:
'200': '200':
@ -403,6 +483,7 @@ paths:
type: integer type: integer
get: get:
tags: tags:
- location
- shift - shift
summary: Get all shifts in the requested location summary: Get all shifts in the requested location
responses: responses:
@ -424,6 +505,28 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFoundError' $ref: '#/components/responses/NotFoundError'
/users/self:
get:
tags:
- user
summary: Get the requesting users information
responses:
'200':
description: Ok
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/UserDetail'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/NotFoundError'
/users/{id}/angeltypes: /users/{id}/angeltypes:
parameters: parameters:
- name: id - name: id
@ -435,6 +538,7 @@ paths:
type: integer type: integer
get: get:
tags: tags:
- angeltype
- user - user
summary: Get the users angel types summary: Get the users angel types
responses: responses:
@ -456,6 +560,39 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFoundError' $ref: '#/components/responses/NotFoundError'
/users/{id}/shifts:
parameters:
- name: id
in: path
required: true
description: The user identifier
example: 42
schema:
type: integer
get:
tags:
- shift
- user
summary: Get all shifts of the requested user
responses:
'200':
description: Ok
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Shift'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/NotFoundError'
/openapi: /openapi:
get: get:
tags: tags:

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api\Resources;
class UserDetailResource extends UserResource
{
public function toArray(): array
{
return array_merge(parent::toArray(), [
'email' => $this->model->contact->email ?: $this->model->email,
'tshirt' => $this->model->personalData->shirt_size,
'language' => $this->model->settings->language,
'arrived' => $this->model->state->arrived,
'dates' => [
'planned_arrival' => $this->model->personalData->planned_arrival_date,
'planned_departure' => $this->model->personalData->planned_departure_date,
'arrival' => $this->model->state->arrival_date,
],
]);
}
}

View File

@ -10,13 +10,44 @@ use Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource;
use Engelsystem\Controllers\Api\Resources\UserResource; use Engelsystem\Controllers\Api\Resources\UserResource;
use Engelsystem\Http\Request; use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\AngelType;
use Engelsystem\Models\Location; use Engelsystem\Models\Location;
use Engelsystem\Models\Shifts\NeededAngelType; use Engelsystem\Models\Shifts\NeededAngelType;
use Engelsystem\Models\Shifts\Shift; use Engelsystem\Models\Shifts\Shift;
use Engelsystem\Models\Shifts\ShiftEntry;
use Engelsystem\Models\User\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class ShiftsController extends ApiController class ShiftsController extends ApiController
{ {
public function entriesByAngeltype(Request $request): Response
{
$id = (int) $request->getAttribute('angeltype_id');
/** @var AngelType $angeltype */
$angeltype = AngelType::findOrFail($id);
/** @var ShiftEntry[]|Collection $shifts */
$shiftEntries = $angeltype->shiftEntries()
->with([
'shift.neededAngelTypes.angelType',
'shift.location.neededAngelTypes.angelType',
'shift.shiftEntries.angelType',
'shift.shiftEntries.user.contact',
'shift.shiftEntries.user.personalData',
'shift.shiftType',
'shift.schedule',
])
->get();
/** @var Shift[]|Collection $shifts */
$shifts = Collection::make(
$shiftEntries
->pluck('shift')
->sortBy('start')
);
return $this->shiftEntriesResponse($shifts);
}
public function entriesByLocation(Request $request): Response public function entriesByLocation(Request $request): Response
{ {
$locationId = (int) $request->getAttribute('location_id'); $locationId = (int) $request->getAttribute('location_id');
@ -31,10 +62,45 @@ class ShiftsController extends ApiController
'shiftEntries.user.contact', 'shiftEntries.user.contact',
'shiftEntries.user.personalData', 'shiftEntries.user.personalData',
'shiftType', 'shiftType',
'schedule',
]) ])
->orderBy('start') ->orderBy('start')
->get(); ->get();
return $this->shiftEntriesResponse($shifts);
}
public function entriesByUser(Request $request): Response
{
$id = (int) $request->getAttribute('user_id');
/** @var User $user */
$user = User::findOrFail($id);
/** @var ShiftEntry[]|Collection $shifts */
$shiftEntries = $user->shiftEntries()
->with([
'shift.neededAngelTypes.angelType',
'shift.location.neededAngelTypes.angelType',
'shift.shiftEntries.angelType',
'shift.shiftEntries.user.contact',
'shift.shiftEntries.user.personalData',
'shift.shiftType',
'shift.schedule',
])
->get();
/** @var Shift[]|Collection $shifts */
$shifts = Collection::make(
$shiftEntries
->pluck('shift')
->sortBy('start')
);
return $this->shiftEntriesResponse($shifts);
}
protected function shiftEntriesResponse(Collection $shifts): Response
{
/** @var Collection|Shift[] $shifts */
$shiftEntries = []; $shiftEntries = [];
// Blob of not-optimized mediocre pseudo-serialization // Blob of not-optimized mediocre pseudo-serialization
foreach ($shifts as $shift) { foreach ($shifts as $shift) {
@ -58,7 +124,7 @@ class ShiftsController extends ApiController
]); ]);
} }
$locationData = new LocationResource($location); $locationData = new LocationResource($shift->location);
$shiftEntries[] = (new ShiftWithEntriesResource($shift))->toArray($locationData, $entries); $shiftEntries[] = (new ShiftWithEntriesResource($shift))->toArray($locationData, $entries);
} }
@ -72,22 +138,12 @@ class ShiftsController extends ApiController
*/ */
protected function getNeededAngelTypes(Shift $shift): Collection protected function getNeededAngelTypes(Shift $shift): Collection
{ {
// From shift if (!$shift->schedule) {
// Get from shift
$neededAngelTypes = $shift->neededAngelTypes; $neededAngelTypes = $shift->neededAngelTypes;
} else {
// Add from location // Load instead from location
foreach ($shift->location->neededAngelTypes as $neededAngelType) { $neededAngelTypes = $shift->location->neededAngelTypes;
/** @var NeededAngelType $existingNeededAngelType */
$existingNeededAngelType = $neededAngelTypes
->where('angel_type_id', $neededAngelType->angel_type_id)
->first();
if (!$existingNeededAngelType) {
$neededAngelTypes[] = clone $neededAngelType;
continue;
}
$existingNeededAngelType->location_id = $shift->location->id;
$existingNeededAngelType->count += $neededAngelType->count;
} }
// Add needed angeltypes from additionally added users // Add needed angeltypes from additionally added users

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Api;
use Engelsystem\Controllers\Api\Resources\UserDetailResource;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Response;
class UsersController extends ApiController
{
public function __construct(Response $response, protected Authenticator $auth)
{
parent::__construct($response);
}
public function self(): Response
{
$user = $this->auth->user();
$data = ['data' => (new UserDetailResource($user))->toArray()];
return $this->response
->withContent(json_encode($data));
}
}

View File

@ -10,6 +10,8 @@ use Engelsystem\Http\Request;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\Location; use Engelsystem\Models\Location;
use Engelsystem\Models\Shifts\NeededAngelType; use Engelsystem\Models\Shifts\NeededAngelType;
use Engelsystem\Models\Shifts\Schedule;
use Engelsystem\Models\Shifts\ScheduleShift;
use Engelsystem\Models\Shifts\Shift; use Engelsystem\Models\Shifts\Shift;
use Engelsystem\Models\Shifts\ShiftEntry; use Engelsystem\Models\Shifts\ShiftEntry;
use Engelsystem\Models\User\Contact; use Engelsystem\Models\User\Contact;
@ -18,8 +20,13 @@ use Engelsystem\Models\User\User;
class ShiftsControllerTest extends ApiBaseControllerTest class ShiftsControllerTest extends ApiBaseControllerTest
{ {
protected Location $location;
protected Shift $shiftA;
protected Shift $shiftB;
/** /**
* @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByLocation * @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByLocation
* @covers \Engelsystem\Controllers\Api\ShiftsController::shiftEntriesResponse
* @covers \Engelsystem\Controllers\Api\Resources\ShiftResource::toArray * @covers \Engelsystem\Controllers\Api\Resources\ShiftResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\ShiftTypeResource::toArray * @covers \Engelsystem\Controllers\Api\Resources\ShiftTypeResource::toArray
* @covers \Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource::toArray * @covers \Engelsystem\Controllers\Api\Resources\ShiftWithEntriesResource::toArray
@ -29,64 +36,8 @@ class ShiftsControllerTest extends ApiBaseControllerTest
*/ */
public function testEntriesByLocation(): void public function testEntriesByLocation(): void
{ {
$this->initDatabase();
/** @var Location $location */
$location = Location::factory()->create();
// Shifts
/** @var Shift $shiftA */
$shiftA = Shift::factory(1)
->create(['location_id' => $location->id, 'start' => Carbon::now()->subHour()])
->first();
/** @var Shift $shiftB */
$shiftB = Shift::factory(1)
->create(['location_id' => $location->id, 'start' => Carbon::now()->addHour()])
->first();
// "Empty" entry to be skipped
NeededAngelType::factory(1)->create(['location_id' => null, 'shift_id' => $shiftA->id, 'count' => 0]);
// Needed entry by shift
/** @var NeededAngelType $byShift */
$byShift = NeededAngelType::factory(2)
->create(['location_id' => null, 'shift_id' => $shiftA->id, 'count' => 2])
->first();
// Needed entry by location
/** @var NeededAngelType $byLocation */
$byLocation = NeededAngelType::factory(1)
->create(['location_id' => $location->id, 'shift_id' => null, 'count' => 3])
->first();
// Added by both
NeededAngelType::factory(1)
->create([
'location_id' => $location->id,
'shift_id' => null,
'angel_type_id' => $byShift->angel_type_id,
'count' => 3,
])
->first();
// By shift
ShiftEntry::factory(2)->create(['shift_id' => $shiftA->id, 'angel_type_id' => $byShift->angel_type_id]);
// By location
ShiftEntry::factory(1)->create(['shift_id' => $shiftA->id, 'angel_type_id' => $byLocation->angel_type_id]);
// Additional (not required by shift nor location)
ShiftEntry::factory(1)->create(['shift_id' => $shiftA->id]);
foreach (User::all() as $user) {
// Generate user data
/** @var User $user */
PersonalData::factory()->create(['user_id' => $user->id]);
Contact::factory()->create(['user_id' => $user->id]);
}
$request = new Request(); $request = new Request();
$request = $request->withAttribute('location_id', $location->id); $request = $request->withAttribute('location_id', $this->location->id);
$controller = new ShiftsController(new Response()); $controller = new ShiftsController(new Response());
@ -102,15 +53,15 @@ class ShiftsControllerTest extends ApiBaseControllerTest
// First shift // First shift
$shiftAData = $data['data'][0]; $shiftAData = $data['data'][0];
$this->assertEquals($shiftA->title, $shiftAData['title'], 'Title is equal'); $this->assertEquals($this->shiftA->title, $shiftAData['title'], 'Title is equal');
$this->assertEquals($location->id, $shiftAData['location']['id'], 'Same location'); $this->assertEquals($this->location->id, $shiftAData['location']['id'], 'Same location');
$this->assertEquals($shiftA->shiftType->id, $shiftAData['shift_type']['id'], 'Shift type equals'); $this->assertEquals($this->shiftA->shiftType->id, $shiftAData['shift_type']['id'], 'Shift type equals');
$this->assertCount(4, $shiftAData['entries']); $this->assertCount(4, $shiftAData['entries']);
// Has users // Has users
$entriesA = collect($shiftAData['entries'])->sortBy('type.id'); $entriesA = collect($shiftAData['entries'])->sortBy('type.id');
$entry = $entriesA[0]; $entry = $entriesA[0];
$this->assertCount(2, $entry['users']); $this->assertCount(2, $entry['users']);
$this->assertEquals(5, $entry['needs']); $this->assertEquals(2, $entry['needs']);
$user = $entry['users'][0]; $user = $entry['users'][0];
$this->assertEquals('/users?action=view&user_id=' . $user['id'], $user['url']); $this->assertEquals('/users?action=view&user_id=' . $user['id'], $user['url']);
$this->assertCount(0, $entriesA[1]['users']); $this->assertCount(0, $entriesA[1]['users']);
@ -119,12 +70,130 @@ class ShiftsControllerTest extends ApiBaseControllerTest
// Second (empty) shift // Second (empty) shift
$shiftBData = $data['data'][1]; $shiftBData = $data['data'][1];
$this->assertEquals($shiftB->title, $shiftBData['title'], 'Title is equal'); $this->assertEquals($this->shiftB->title, $shiftBData['title'], 'Title is equal');
$this->assertEquals($location->id, $shiftBData['location']['id'], 'Same location'); $this->assertEquals($this->location->id, $shiftBData['location']['id'], 'Same location');
$this->assertEquals($shiftB->shiftType->id, $shiftBData['shift_type']['id'], 'Shift type equals'); $this->assertEquals($this->shiftB->shiftType->id, $shiftBData['shift_type']['id'], 'Shift type equals');
$this->assertCount(2, $shiftBData['entries']); $this->assertCount(2, $shiftBData['entries']);
// No users // No users
$entriesB = collect($shiftBData['entries'])->sortBy('type.id'); $entriesB = collect($shiftBData['entries'])->sortBy('type.id');
$this->assertCount(0, $entriesB[0]['users']); $this->assertCount(0, $entriesB[0]['users']);
} }
/**
* @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByAngeltype
*/
public function testEntriesByAngeltype(): void
{
/** @var ShiftEntry $firstEntry */
$firstEntry = $this->shiftA->shiftEntries->first();
$request = new Request();
$request = $request->withAttribute('angeltype_id', $firstEntry->angelType->id);
$controller = new ShiftsController(new Response());
$response = $controller->entriesByAngeltype($request);
$this->validateApiResponse('/angeltypes/{id}/shifts', 'get', $response);
$this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('data', $data);
$this->assertCount(2, $data['data']);
$shift = $data['data'][0];
$this->assertTrue(count($shift['entries']) >= 1);
}
/**
* @covers \Engelsystem\Controllers\Api\ShiftsController::entriesByUser
*/
public function testEntriesByUser(): void
{
/** @var ShiftEntry $firstEntry */
$firstEntry = $this->shiftA->shiftEntries->first();
$request = new Request();
$request = $request->withAttribute('user_id', $firstEntry->user->id);
$controller = new ShiftsController(new Response());
$response = $controller->entriesByUser($request);
$this->validateApiResponse('/users/{id}/shifts', 'get', $response);
$this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('data', $data);
$this->assertCount(1, $data['data']);
$shift = $data['data'][0];
$this->assertTrue(count($shift['entries']) >= 1);
}
public function setUp(): void
{
parent::setUp();
$this->location = Location::factory()->create();
$schedule = Schedule::factory()->create();
// Shifts
$this->shiftA = Shift::factory(1)
->create(['location_id' => $this->location->id, 'start' => Carbon::now()->subHour()])
->first();
$this->shiftB = Shift::factory(1)
->create(['location_id' => $this->location->id, 'start' => Carbon::now()->addHour()])
->first();
(new ScheduleShift(['shift_id' => $this->shiftB->id, 'schedule_id' => $schedule->id, 'guid' => 'a']))->save();
// "Empty" entry to be skipped
NeededAngelType::factory(1)->create(['location_id' => null, 'shift_id' => $this->shiftA->id, 'count' => 0]);
// Needed entry by shift
/** @var NeededAngelType $byShift */
$byShift = NeededAngelType::factory(2)
->create(['location_id' => null, 'shift_id' => $this->shiftA->id, 'count' => 2])
->first();
// Needed entry by location
/** @var NeededAngelType $byLocation */
$byLocation = NeededAngelType::factory(1)
->create(['location_id' => $this->location->id, 'shift_id' => null, 'count' => 3])
->first();
// Added by both
NeededAngelType::factory(1)
->create([
'location_id' => $this->location->id,
'shift_id' => null,
'angel_type_id' => $byShift->angel_type_id,
'count' => 3,
])
->first();
// By shift
ShiftEntry::factory(2)->create([
'shift_id' => $this->shiftA->id,
'angel_type_id' => $byShift->angel_type_id,
]);
// By location
ShiftEntry::factory(1)->create([
'shift_id' => $this->shiftA->id,
'angel_type_id' => $byLocation->angel_type_id,
]);
// Additional (not required by shift nor location)
ShiftEntry::factory(1)->create(['shift_id' => $this->shiftA->id]);
foreach (User::all() as $user) {
// Generate user data
/** @var User $user */
PersonalData::factory()->create(['user_id' => $user->id]);
Contact::factory()->create(['user_id' => $user->id]);
}
}
} }

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Api;
use Engelsystem\Controllers\Api\UsersController;
use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Response;
use Engelsystem\Models\User\Contact;
use Engelsystem\Models\User\PersonalData;
use Engelsystem\Models\User\Settings;
use Engelsystem\Models\User\State;
use Engelsystem\Models\User\User;
use PHPUnit\Framework\MockObject\MockObject;
class UsersControllerTest extends ApiBaseControllerTest
{
/**
* @covers \Engelsystem\Controllers\Api\UsersController::__construct
* @covers \Engelsystem\Controllers\Api\UsersController::self
* @covers \Engelsystem\Controllers\Api\Resources\UserDetailResource::toArray
*/
public function testSelf(): void
{
$user = User::factory()
->has(Contact::factory())
->has(PersonalData::factory())
->has(Settings::factory())
->has(State::factory())
->create();
/** @var Authenticator|MockObject $auth */
$auth = $this->createMock(Authenticator::class);
$this->setExpects($auth, 'user', null, $user);
$controller = new UsersController(new Response(), $auth);
$response = $controller->self();
$this->validateApiResponse('/users/self', 'get', $response);
$this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('data', $data);
$this->assertArrayHasKey('id', $data['data']);
$this->assertEquals($user->id, $data['data']['id']);
$this->assertArrayHasKey('dates', $data['data']);
}
}