diff --git a/config/routes.php b/config/routes.php index 91fb30ab..cd61a709 100644 --- a/config/routes.php +++ b/config/routes.php @@ -96,6 +96,16 @@ $route->addGroup( } ); + // User + $route->addGroup( + '/user/{id:\d+}', + // Shirts + function (RouteCollector $route) { + $route->get('/shirt', 'Admin\\UserShirtController@editShirt'); + $route->post('/shirt', 'Admin\\UserShirtController@saveShirt'); + } + ); + // News $route->addGroup( '/news', diff --git a/db/migrations/2021_08_26_000000_add_shirt_edit_permissions.php b/db/migrations/2021_08_26_000000_add_shirt_edit_permissions.php new file mode 100644 index 00000000..d4459a14 --- /dev/null +++ b/db/migrations/2021_08_26_000000_add_shirt_edit_permissions.php @@ -0,0 +1,63 @@ +schema->hasTable('GroupPrivileges')) { + return; + } + + $db = $this->schema->getConnection(); + $db->table('Privileges') + ->insert(['name' => 'user.edit.shirt', 'desc' => 'Edit user shirts']); + + $shiftCoordinator = -40; + $shirtManager = -30; + + $userEditShirt = $db->table('Privileges') + ->where('name', 'user.edit.shirt') + ->get(['id'])->first(); + $adminArrive = $db->table('Privileges') + ->where('name', 'admin_arrive') + ->get(['id'])->first(); + + $db->table('GroupPrivileges') + ->insertOrIgnore([ + ['group_id' => $shiftCoordinator, 'privilege_id' => $userEditShirt->id], + ['group_id' => $shirtManager, 'privilege_id' => $userEditShirt->id], + ['group_id' => $shirtManager, 'privilege_id' => $adminArrive->id], + ]); + } + + /** + * Reverse the migration + */ + public function down() + { + if (!$this->schema->hasTable('GroupPrivileges')) { + return; + } + + $db = $this->schema->getConnection(); + $db->table('Privileges') + ->where(['name' => 'user.edit.shirt']) + ->delete(); + + $shirtManager = -30; + $adminArrive = $db->table('Privileges') + ->where('name', 'admin_arrive') + ->get(['id'])->first(); + + $db->table('GroupPrivileges') + ->where(['group_id' => $shirtManager, 'privilege_id' => $adminArrive->id]) + ->delete(); + } +} diff --git a/includes/pages/admin_active.php b/includes/pages/admin_active.php index cb1a600b..a9f6cce7 100644 --- a/includes/pages/admin_active.php +++ b/includes/pages/admin_active.php @@ -272,6 +272,8 @@ function admin_active() ); } + $actions[] = button(url('/admin/user/' . $usr->id . '/shirt'), __('form.edit'), 'btn-primary btn-sm'); + $userData['actions'] = buttons($actions); $matched_users[] = $userData; diff --git a/includes/view/User_view.php b/includes/view/User_view.php index 54329db9..199a1863 100644 --- a/includes/view/User_view.php +++ b/includes/view/User_view.php @@ -618,6 +618,10 @@ function User_view( div('row', [ div('col-md-12', [ buttons([ + $auth->can('user.edit.shirt') ? button( + url('/admin/user/' . $user_source->id . '/shirt'), + icon('person') . __('Shirt') + ) : '', $admin_user_privilege ? button( page_link_to('admin_user', ['id' => $user_source->id]), icon('pencil-square') . __('edit') diff --git a/resources/lang/de_DE/additional.po b/resources/lang/de_DE/additional.po index 9b1dd9e8..48dc9c4d 100644 --- a/resources/lang/de_DE/additional.po +++ b/resources/lang/de_DE/additional.po @@ -138,3 +138,6 @@ msgstr "Es gibt eine neue News: %1$s" msgid "notification.news.new.text" msgstr "Du kannst sie dir unter %3$s anschauen." + +msgid "user.edit.success" +msgstr "Benutzer erfolgreich bearbeitet." diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index 55e6efa5..c33e4b11 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -2909,7 +2909,6 @@ msgstr "Level" msgid "log.message" msgstr "Nachricht" - msgid "settings.settings" msgstr "Einstellungen" @@ -2978,3 +2977,24 @@ msgstr "Frage" msgid "question.answer" msgstr "Antwort" + +msgid "user.edit.shirt" +msgstr "Shirt bearbeiten" + +msgid "form.shirt" +msgstr "Shirt" + +msgid "user.shirt_size" +msgstr "Shirt größe" + +msgid "user.active" +msgstr "Aktiv" + +msgid "user.force_active" +msgstr "Aktiv (erzwungen)" + +msgid "user.arrived" +msgstr "Angekommen" + +msgid "user.got_shirt" +msgstr "Shirt bekommen" diff --git a/resources/lang/en_US/additional.po b/resources/lang/en_US/additional.po index 58ac24a7..2b399111 100644 --- a/resources/lang/en_US/additional.po +++ b/resources/lang/en_US/additional.po @@ -134,3 +134,6 @@ msgstr "A new news is available: %1$s" msgid "notification.news.new.text" msgstr "You can watch it at %3$s" + +msgid "user.edit.success" +msgstr "User edited successfully." diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index b63f882a..16c25f19 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -260,3 +260,24 @@ msgstr "Question" msgid "question.answer" msgstr "Answer" + +msgid "user.edit.shirt" +msgstr "Edit shirt" + +msgid "form.shirt" +msgstr "Shirt" + +msgid "user.shirt_size" +msgstr "Shirt size" + +msgid "user.active" +msgstr "Active" + +msgid "user.force_active" +msgstr "Active (forced)" + +msgid "user.arrived" +msgstr "Arrived" + +msgid "user.got_shirt" +msgstr "Got shirt" diff --git a/resources/views/admin/user/edit-shirt.twig b/resources/views/admin/user/edit-shirt.twig new file mode 100644 index 00000000..e7ac5631 --- /dev/null +++ b/resources/views/admin/user/edit-shirt.twig @@ -0,0 +1,39 @@ +{% extends "layouts/app.twig" %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ __('user.edit.shirt') }}{% endblock %} + +{% block content %} +
+

{{ block('title') }}

+ + {% include 'layouts/parts/messages.twig' %} + +
+ {{ csrf() }} + +
+
+ {{ f.select('shirt_size', config('tshirt_sizes'), __('user.shirt_size'), userdata.personalData.shirt_size) }} +
+
+ {% if has_permission_to('admin_arrive') %} + {{ f.switch('arrived', __('user.arrived'), userdata.state.arrived) }} + {% endif %} + + {% if userdata.state.force_active %} + {{ f.switch('force_active', __('user.force_active'), true, {'disabled': true}) }} + {% endif %} + + {{ f.switch('active', __('user.active'), userdata.state.active) }} + + {{ f.switch('got_shirt', __('user.got_shirt'), userdata.state.got_shirt) }} +
+
+ {{ f.submit(__('form.save')) }} +
+
+
+
+{% endblock %} diff --git a/resources/views/macros/form.twig b/resources/views/macros/form.twig index 9fed02b5..70c966ec 100644 --- a/resources/views/macros/form.twig +++ b/resources/views/macros/form.twig @@ -44,17 +44,18 @@ {% endif %} {%- endmacro %} -{% macro checkbox(name, label, checked, value) %} -
+{% macro checkbox(name, label, checked, value, disabled) %} +
@@ -81,3 +82,13 @@ {% macro submit(label, opt) %} {{ _self.button(label|default(__('form.submit')), {'type': 'submit', 'btn_type': 'primary'}|merge(opt|default({}))) }} {%- endmacro %} + +{% macro switch(name, label, checked, opt) %} +
+ + +
+{%- endmacro %} diff --git a/src/Controllers/Admin/UserShirtController.php b/src/Controllers/Admin/UserShirtController.php new file mode 100644 index 00000000..240674f7 --- /dev/null +++ b/src/Controllers/Admin/UserShirtController.php @@ -0,0 +1,129 @@ + 'user.edit.shirt', + 'saveShirt' => 'user.edit.shirt', + ]; + + /** + * @param Authenticator $auth + * @param Config $config + * @param LoggerInterface $log + * @param Redirector $redirector + * @param Response $response + * @param User $user + */ + public function __construct( + Authenticator $auth, + Config $config, + LoggerInterface $log, + Redirector $redirector, + Response $response, + User $user + ) { + $this->auth = $auth; + $this->config = $config; + $this->log = $log; + $this->redirect = $redirector; + $this->response = $response; + $this->user = $user; + } + + /** + * @param Request $request + * + * @return Response + */ + public function editShirt(Request $request): Response + { + $id = $request->getAttribute('id'); + $user = $this->user->findOrFail($id); + + return $this->response->withView( + 'admin/user/edit-shirt.twig', + ['userdata' => $user] + $this->getNotifications() + ); + } + + /** + * @param Request $request + * + * @return Response + */ + public function saveShirt(Request $request): Response + { + $id = $request->getAttribute('id'); + /** @var User $user */ + $user = $this->user->findOrFail($id); + + $data = $this->validate($request, [ + 'shirt_size' => 'required', + 'arrived' => 'optional|checked', + 'active' => 'optional|checked', + 'got_shirt' => 'optional|checked', + ]); + + if (isset($this->config->get('tshirt_sizes')[$data['shirt_size']])) { + $user->personalData->shirt_size = $data['shirt_size']; + $user->personalData->save(); + } + + if ($this->auth->can('admin_arrive')) { + $user->state->arrived = (bool)$data['arrived']; + } + + $user->state->active = (bool)$data['active']; + $user->state->got_shirt = (bool)$data['got_shirt']; + $user->state->save(); + + $this->log->info( + 'Updated user shirt state "{user}" ({id}): {size}, arrived: {arrived}, got shirt: {got_shirt}', + [ + 'id' => $user->id, + 'user' => $user->name, + 'size' => $user->personalData->shirt_size, + 'arrived' => $user->state->arrived, + 'got_shirt' => $user->state->got_shirt + ] + ); + + $this->addNotification('user.edit.success'); + + return $this->redirect->back(); + } +} diff --git a/src/Models/User/State.php b/src/Models/User/State.php index 471b6758..10576e27 100644 --- a/src/Models/User/State.php +++ b/src/Models/User/State.php @@ -33,6 +33,15 @@ class State extends HasUserModel 'arrival_date', ]; + /** @var array */ + protected $casts = [ + 'arrived' => 'boolean', + 'active' => 'boolean', + 'force_active' => 'boolean', + 'got_shirt' => 'boolean', + 'got_voucher' => 'integer', + ]; + /** The attributes that are mass assignable */ protected $fillable = [ 'user_id', diff --git a/tests/Unit/Controllers/Admin/UserShirtControllerTest.php b/tests/Unit/Controllers/Admin/UserShirtControllerTest.php new file mode 100644 index 00000000..414996f1 --- /dev/null +++ b/tests/Unit/Controllers/Admin/UserShirtControllerTest.php @@ -0,0 +1,163 @@ +request->withAttribute('id', 1); + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + /** @var Redirector|MockObject $redirector */ + $redirector = $this->createMock(Redirector::class); + $user = new User(); + User::factory()->create(); + + $this->setExpects($this->response, 'withView', ['admin/user/edit-shirt.twig'], $this->response); + + $controller = new UserShirtController($auth, $this->config, $this->log, $redirector, $this->response, $user); + + $controller->editShirt($request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\UserShirtController::editShirt + */ + public function testIndexUserNotFound() + { + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + /** @var Redirector|MockObject $redirector */ + $redirector = $this->createMock(Redirector::class); + $user = new User(); + + $controller = new UserShirtController($auth, $this->config, $this->log, $redirector, $this->response, $user); + + $this->expectException(ModelNotFoundException::class); + $controller->editShirt($this->request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\UserShirtController::saveShirt + */ + public function testSaveShirt() + { + $request = $this->request + ->withAttribute('id', 1) + ->withParsedBody([ + 'shirt_size' => 'S', + ]); + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + $this->config->set('tshirt_sizes', ['S' => 'Small']); + /** @var Redirector|MockObject $redirector */ + $redirector = $this->createMock(Redirector::class); + User::factory() + ->has(State::factory()) + ->has(PersonalData::factory()) + ->create(); + + $auth + ->expects($this->exactly(4)) + ->method('can') + ->with('admin_arrive') + ->willReturnOnConsecutiveCalls(true, true, true, false); + $this->setExpects($redirector, 'back', null, $this->response, $this->exactly(4)); + + $controller = new UserShirtController( + $auth, + $this->config, + $this->log, + $redirector, + $this->response, + new User() + ); + $controller->setValidator(new Validator()); + + // Set shirt size + $controller->saveShirt($request); + + $this->assertHasNotification('user.edit.success'); + $this->assertTrue($this->log->hasInfoThatContains('Updated user shirt state')); + + $user = User::find(1); + $this->assertEquals('S', $user->personalData->shirt_size); + $this->assertFalse($user->state->arrived); + $this->assertFalse($user->state->active); + $this->assertFalse($user->state->got_shirt); + + // Set active, arrived and got_shirt + $request = $request + ->withParsedBody([ + 'shirt_size' => 'S', + 'arrived' => '1', + 'active' => '1', + 'got_shirt' => '1', + ]); + + $controller->saveShirt($request); + + $user = User::find(1); + $this->assertTrue($user->state->active); + $this->assertTrue($user->state->arrived); + $this->assertTrue($user->state->got_shirt); + + // Shirt size not available + $request = $request + ->withParsedBody([ + 'shirt_size' => 'L', + ]); + + $controller->saveShirt($request); + $user = User::find(1); + $this->assertEquals('S', $user->personalData->shirt_size); + + // Not allowed changing arrived + $request = $request + ->withParsedBody([ + 'shirt_size' => 'S', + 'arrived' => '1', + ]); + + $this->assertFalse($user->state->arrived); + $controller->saveShirt($request); + $user = User::find(1); + $this->assertFalse($user->state->arrived); + } + + /** + * @covers \Engelsystem\Controllers\Admin\UserShirtController::saveShirt + */ + public function testSaveShirtUserNotFound() + { + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + /** @var Redirector|MockObject $redirector */ + $redirector = $this->createMock(Redirector::class); + $user = new User(); + + $controller = new UserShirtController($auth, $this->config, $this->log, $redirector, $this->response, $user); + + $this->expectException(ModelNotFoundException::class); + $controller->editShirt($this->request); + } +} diff --git a/tests/Unit/HasDatabase.php b/tests/Unit/HasDatabase.php index be48038b..f5c87491 100644 --- a/tests/Unit/HasDatabase.php +++ b/tests/Unit/HasDatabase.php @@ -53,6 +53,7 @@ trait HasDatabase ['migration' => '2020_04_07_000000_change_mysql_database_encoding_to_utf8mb4'], ['migration' => '2020_09_12_000000_create_welcome_angel_permissions_group'], ['migration' => '2020_12_28_000000_oauth_set_identifier_binary'], + ['migration' => '2021_08_26_000000_add_shirt_edit_permissions'], ] );