From b5c974b9e322321bab1318b44ac905f8a183db57 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Fri, 18 Dec 2020 18:27:10 +0100 Subject: [PATCH] Add new questions frontend --- config/config.default.php | 2 +- config/routes.php | 17 ++ ...30_000000_create_questions_permissions.php | 65 +++++ includes/includes.php | 3 - includes/pages/admin_questions.php | 166 ----------- includes/pages/user_questions.php | 79 ------ includes/sys_menu.php | 78 +++-- includes/view/Questions_view.php | 71 ----- resources/lang/de_DE/additional.po | 9 + resources/lang/de_DE/default.po | 15 + resources/lang/en_US/additional.po | 9 + resources/lang/en_US/default.po | 15 + resources/views/macros/base.twig | 4 +- resources/views/pages/questions/edit.twig | 79 ++++++ resources/views/pages/questions/overview.twig | 83 ++++++ src/Controllers/Admin/QuestionsController.php | 177 ++++++++++++ src/Controllers/QuestionsController.php | 145 ++++++++++ src/Middleware/LegacyMiddleware.php | 8 - .../Admin/QuestionsControllerTest.php | 267 ++++++++++++++++++ tests/Unit/Controllers/ControllerTest.php | 11 + .../Controllers/QuestionsControllerTest.php | 215 ++++++++++++++ 21 files changed, 1168 insertions(+), 350 deletions(-) create mode 100644 db/migrations/2020_09_30_000000_create_questions_permissions.php delete mode 100644 includes/pages/admin_questions.php delete mode 100644 includes/pages/user_questions.php delete mode 100644 includes/view/Questions_view.php create mode 100644 resources/views/pages/questions/edit.twig create mode 100644 resources/views/pages/questions/overview.twig create mode 100644 src/Controllers/Admin/QuestionsController.php create mode 100644 src/Controllers/QuestionsController.php create mode 100644 tests/Unit/Controllers/Admin/QuestionsControllerTest.php create mode 100644 tests/Unit/Controllers/QuestionsControllerTest.php diff --git a/config/config.default.php b/config/config.default.php index 00b8c92d..8b279c65 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -126,7 +126,7 @@ return [ ], // Redirect to this site after logging in or when pressing the top-left button - // Must be one of news, meetings, user_shifts, angeltypes, user_questions + // Must be one of news, meetings, user_shifts, angeltypes, questions 'home_site' => env('HOME_SITE', 'news'), // Number of News shown on one site diff --git a/config/routes.php b/config/routes.php index c0a562ab..f5a7e45b 100644 --- a/config/routes.php +++ b/config/routes.php @@ -43,6 +43,12 @@ $route->post('/news/{id:\d+}', 'NewsController@comment'); // FAQ $route->get('/faq', 'FaqController@index'); +// Questions +$route->get('/questions', 'QuestionsController@index'); +$route->post('/questions', 'QuestionsController@delete'); +$route->get('/questions/new', 'QuestionsController@add'); +$route->post('/questions/new', 'QuestionsController@save'); + // API $route->get('/api[/{resource:.+}]', 'ApiController@index'); @@ -78,6 +84,17 @@ $route->addGroup( } ); + // Questions + $route->addGroup( + '/questions', + function (RouteCollector $route) { + $route->get('', 'Admin\\QuestionsController@index'); + $route->post('', 'Admin\\QuestionsController@delete'); + $route->get('/{id:\d+}', 'Admin\\QuestionsController@edit'); + $route->post('/{id:\d+}', 'Admin\\QuestionsController@save'); + } + ); + // News $route->addGroup( '/news', diff --git a/db/migrations/2020_09_30_000000_create_questions_permissions.php b/db/migrations/2020_09_30_000000_create_questions_permissions.php new file mode 100644 index 00000000..e90a87c7 --- /dev/null +++ b/db/migrations/2020_09_30_000000_create_questions_permissions.php @@ -0,0 +1,65 @@ +schema->hasTable('Privileges')) { + $db = $this->schema->getConnection(); + $db->table('Privileges')->insert([ + ['name' => 'question.add', 'desc' => 'Ask questions'], + ['name' => 'question.edit', 'desc' => 'Answer questions'], + ]); + + $userGroup = -20; + $shiftCoordinatorGroup = -60; + $addId = $db->table('Privileges')->where('name', 'question.add')->first()->id; + $editId = $db->table('Privileges')->where('name', 'question.edit')->first()->id; + $db->table('GroupPrivileges')->insert([ + ['group_id' => $userGroup, 'privilege_id' => $addId], + ['group_id' => $shiftCoordinatorGroup, 'privilege_id' => $editId], + ]); + + $db->table('Privileges') + ->whereIn('name', ['user_questions', 'admin_questions']) + ->delete(); + } + } + + /** + * Reverse the migration + */ + public function down() + { + if (!$this->schema->hasTable('Privileges')) { + return; + } + + $db = $this->schema->getConnection(); + $db->table('Privileges') + ->whereIn('name', ['question.add', 'question.edit']) + ->delete(); + + $db->table('Privileges')->insert([ + ['name' => 'user_questions', 'desc' => 'Let users ask questions'], + ['name' => 'admin_questions', 'desc' => 'Answer user\'s questions'], + ]); + $userGroup = -20; + $shiftCoordinatorGroup = -60; + $bureaucratGroup = -40; + $userQuestionsId = $db->table('Privileges')->where('name', 'user_questions')->first()->id; + $adminQuestionsId = $db->table('Privileges')->where('name', 'admin_questions')->first()->id; + $db->table('GroupPrivileges')->insert([ + ['group_id' => $userGroup, 'privilege_id' => $userQuestionsId], + ['group_id' => $shiftCoordinatorGroup, 'privilege_id' => $adminQuestionsId], + ['group_id' => $bureaucratGroup, 'privilege_id' => $adminQuestionsId], + ]); + } +} diff --git a/includes/includes.php b/includes/includes.php index ffbc84cc..061619dd 100644 --- a/includes/includes.php +++ b/includes/includes.php @@ -30,7 +30,6 @@ $includeFiles = [ __DIR__ . '/../includes/view/AngelTypes_view.php', __DIR__ . '/../includes/view/EventConfig_view.php', __DIR__ . '/../includes/view/PublicDashboard_view.php', - __DIR__ . '/../includes/view/Questions_view.php', __DIR__ . '/../includes/view/Rooms_view.php', __DIR__ . '/../includes/view/ShiftCalendarLane.php', __DIR__ . '/../includes/view/ShiftCalendarRenderer.php', @@ -68,14 +67,12 @@ $includeFiles = [ __DIR__ . '/../includes/pages/admin_arrive.php', __DIR__ . '/../includes/pages/admin_free.php', __DIR__ . '/../includes/pages/admin_groups.php', - __DIR__ . '/../includes/pages/admin_questions.php', __DIR__ . '/../includes/pages/admin_rooms.php', __DIR__ . '/../includes/pages/admin_shifts.php', __DIR__ . '/../includes/pages/admin_user.php', __DIR__ . '/../includes/pages/guest_login.php', __DIR__ . '/../includes/pages/user_messages.php', __DIR__ . '/../includes/pages/user_myshifts.php', - __DIR__ . '/../includes/pages/user_questions.php', __DIR__ . '/../includes/pages/user_settings.php', __DIR__ . '/../includes/pages/user_shifts.php', diff --git a/includes/pages/admin_questions.php b/includes/pages/admin_questions.php deleted file mode 100644 index 1cdb8b96..00000000 --- a/includes/pages/admin_questions.php +++ /dev/null @@ -1,166 +0,0 @@ -can('admin_questions')) { - $unanswered_questions = Question::unanswered()->count(); - if ($unanswered_questions > 0) { - return '' - . __('There are unanswered questions!') - . ''; - } - } - } - - return null; -} - -/** - * @return string - */ -function admin_questions() -{ - $user = auth()->user(); - $request = request(); - - if (!$request->has('action')) { - $unanswered_questions_table = []; - $unanswered_questions = Question::unanswered()->orderByDesc('created_at')->get(); - - foreach ($unanswered_questions as $question) { - /* @var Question $question */ - $user_source = $question->user; - - $unanswered_questions_table[] = [ - 'from' => User_Nick_render($user_source) . User_Pronoun_render($user_source), - 'question' => nl2br(htmlspecialchars($question->text)), - 'created_at' => $question->created_at, - 'answer' => form([ - form_textarea('answer', '', ''), - form_submit('submit', __('Send')) - ], page_link_to('admin_questions', ['action' => 'answer', 'id' => $question->id])), - 'actions' => form([ - form_submit('submit', __('delete'), 'btn-xs'), - ], page_link_to('admin_questions', ['action' => 'delete', 'id' => $question->id])), - ]; - } - - $answered_questions_table = []; - $answered_questions = Question::answered()->orderByDesc('answered_at')->get(); - - foreach ($answered_questions as $question) { - /* @var Question $question */ - $user_source = $question->user; - $answer_user_source = $question->answerer; - $answered_questions_table[] = [ - 'from' => User_Nick_render($user_source), - 'question' => nl2br(htmlspecialchars($question->text)), - 'created_at' => $question->created_at, - 'answered_by' => User_Nick_render($answer_user_source), - 'answer' => nl2br(htmlspecialchars($question->answer)), - 'answered_at' => $question->answered_at, - 'actions' => form([ - form_submit('submit', __('delete'), 'btn-xs') - ], page_link_to('admin_questions', ['action' => 'delete', 'id' => $question->id])) - ]; - } - - return page_with_title(admin_questions_title(), [ - '

' . __('Unanswered questions') . '

', - table([ - 'from' => __('From'), - 'question' => __('Question'), - 'created_at' => __('Asked at'), - 'answer' => __('Answer'), - 'actions' => '' - ], $unanswered_questions_table), - '

' . __('Answered questions') . '

', - table([ - 'from' => __('From'), - 'question' => __('Question'), - 'created_at' => __('Asked at'), - 'answered_by' => __('Answered by'), - 'answer' => __('Answer'), - 'answered_at' => __('Answered at'), - 'actions' => '' - ], $answered_questions_table) - ]); - } else { - switch ($request->input('action')) { - case 'answer': - if ( - $request->has('id') - && preg_match('/^\d{1,11}$/', $request->input('id')) - && $request->hasPostData('submit') - ) { - $question_id = $request->input('id'); - } else { - return error('Incomplete call, missing Question ID.', true); - } - - $question = Question::find($question_id); - if (!empty($question) && empty($question->answerer_id)) { - $answer = trim($request->input('answer')); - - if (!empty($answer)) { - $question->answerer_id = $user->id; - $question->answer = $answer; - $question->answered_at = Carbon::now(); - $question->save(); - engelsystem_log( - 'Question ' - . $question->text - . ' (' . $question->id . ')' - . ' answered: ' - . $answer - ); - throw_redirect(page_link_to('admin_questions')); - } else { - return error('Enter an answer!', true); - } - } else { - return error('No question found.', true); - } - break; - case 'delete': - if ( - $request->has('id') - && preg_match('/^\d{1,11}$/', $request->input('id')) - && $request->hasPostData('submit') - ) { - $question_id = $request->input('id'); - } else { - return error('Incomplete call, missing Question ID.', true); - } - - $question = Question::find($question_id); - if (!empty($question)) { - $question->delete(); - engelsystem_log('Question deleted: ' . $question->text); - throw_redirect(page_link_to('admin_questions')); - } else { - return error('No question found.', true); - } - break; - } - } - - return ''; -} diff --git a/includes/pages/user_questions.php b/includes/pages/user_questions.php deleted file mode 100644 index 6c3dac92..00000000 --- a/includes/pages/user_questions.php +++ /dev/null @@ -1,79 +0,0 @@ -user(); - $request = request(); - - if (!$request->has('action')) { - $open_questions = $user->questionsAsked() - ->whereNull('answerer_id') - ->orderByDesc('created_at') - ->get(); - $answered_questions = $user->questionsAsked() - ->whereNotNull('answerer_id') - ->orderByDesc('answered_at') - ->get(); - - return Questions_view( - $open_questions->all(), - $answered_questions->all(), - page_link_to('user_questions', ['action' => 'ask']) - ); - } else { - switch ($request->input('action')) { - case 'ask': - $question = request()->get('question'); - if (!empty($question) && $request->hasPostData('submit')) { - Question::create([ - 'user_id' => $user->id, - 'text' => $question, - ]); - - success(__('You question was saved.')); - throw_redirect(page_link_to('user_questions')); - } else { - return page_with_title(questions_title(), [ - error(__('Please enter a question!'), true) - ]); - } - break; - case 'delete': - if ( - $request->has('id') - && preg_match('/^\d{1,11}$/', $request->input('id')) - && $request->hasPostData('submit') - ) { - $question_id = $request->input('id'); - } else { - return error(__('Incomplete call, missing Question ID.'), true); - } - - $question = Question::find($question_id); - if (!empty($question) && $question->user_id == $user->id) { - $question->delete(); - throw_redirect(page_link_to('user_questions')); - } else { - return page_with_title(questions_title(), [ - error(__('No question found.'), true) - ]); - } - break; - } - } - - return ''; -} diff --git a/includes/sys_menu.php b/includes/sys_menu.php index 484abb9b..b6dec73c 100644 --- a/includes/sys_menu.php +++ b/includes/sys_menu.php @@ -1,5 +1,6 @@ __('News'), - 'meetings' => __('Meetings'), + 'meetings' => [__('Meetings'), 'user_meetings'], 'user_shifts' => __('Shifts'), 'angeltypes' => __('Angeltypes'), - 'user_questions' => __('Ask the Heaven'), + 'questions' => [__('Ask the Heaven'), 'question.add'], ]; - foreach ($pages as $menu_page => $title) { - if (auth()->can($menu_page) || ($menu_page == 'meetings' && auth()->can('user_meetings'))) { - $menu[] = toolbar_item_link(page_link_to($menu_page), '', $title, $menu_page == $page); + foreach ($pages as $menu_page => $options) { + if (!menu_is_allowed($menu_page, $options)) { + continue; } + + $title = ((array)$options)[0]; + $menu[] = toolbar_item_link(page_link_to($menu_page), '', $title, $menu_page == $page); } $menu = make_room_navigation($menu); @@ -114,7 +118,7 @@ function make_navigation() 'admin_active' => 'Active angels', 'admin_user' => 'All Angels', 'admin_free' => 'Free angels', - 'admin_questions' => 'Answer questions', + 'admin/questions' => ['Answer questions', 'question.edit'], 'shifttypes' => 'Shifttypes', 'admin_shifts' => 'Create shifts', 'admin_rooms' => 'Rooms', @@ -129,22 +133,17 @@ function make_navigation() } foreach ($admin_pages as $menu_page => $options) { - $options = (array)$options; - $permissions = $menu_page; - $title = $options[0]; - - if (isset($options[1])) { - $permissions = $options[1]; + if (!menu_is_allowed($menu_page, $options)) { + continue; } - if (auth()->can($permissions)) { - $admin_menu[] = toolbar_item_link( - page_link_to($menu_page), - '', - __($title), - $menu_page == $page - ); - } + $title = ((array)$options)[0]; + $admin_menu[] = toolbar_item_link( + page_link_to($menu_page), + '', + __($title), + $menu_page == $page + ); } if (count($admin_menu) > 0) { @@ -154,6 +153,24 @@ function make_navigation() return ''; } +/** + * @param string $page + * @param string|string[] $options + * + * @return bool + */ +function menu_is_allowed(string $page, $options) +{ + $options = (array)$options; + $permissions = $page; + + if (isset($options[1])) { + $permissions = $options[1]; + } + + return auth()->can($permissions); +} + /** * Adds room navigation to the given menu. * @@ -207,3 +224,24 @@ function make_language_select() } return $items; } + +/** + * Renders a hint for new questions to answer. + * + * @return string|null + */ +function admin_new_questions() +{ + if (!auth()->can('question.edit') || current_page() == 'admin/questions') { + return null; + } + + $unanswered_questions = Question::unanswered()->count(); + if (!$unanswered_questions) { + return null; + } + + return '' + . __('There are unanswered questions!') + . ''; +} diff --git a/includes/view/Questions_view.php b/includes/view/Questions_view.php deleted file mode 100644 index 5d96ccd6..00000000 --- a/includes/view/Questions_view.php +++ /dev/null @@ -1,71 +0,0 @@ - form( - [ - form_submit('submit', __('delete'), 'btn-default btn-xs') - ], - page_link_to('user_questions', ['action' => 'delete', 'id' => $question->id]) - ), - 'Question' => nl2br(htmlspecialchars($question->text)), - 'created_at' => $question->created_at, - ]; - }, - $open_questions - ); - - $answered_questions = array_map( - static function (Question $question): array { - return [ - 'Question' => nl2br(htmlspecialchars($question->text)), - 'created_at' => $question->created_at, - 'Answer' => nl2br(htmlspecialchars($question->answer)), - 'answer_user' => User_Nick_render($question->answerer), - 'answered_at' => $question->answered_at, - 'actions' => form( - [ - form_submit('submit', __('delete'), 'btn-default btn-xs') - ], - page_link_to('user_questions', ['action' => 'delete', 'id' => $question->id]) - ), - ]; - }, - $answered_questions - ); - - return page_with_title(questions_title(), [ - msg(), - heading(__('Open questions'), 2), - table([ - 'Question' => __('Question'), - 'created_at' => __('Asked at'), - 'actions' => '' - ], $open_questions), - heading(__('Answered questions'), 2), - table([ - 'Question' => __('Question'), - 'created_at' => __('Asked at'), - 'answer_user' => __('Answered by'), - 'Answer' => __('Answer'), - 'answered_at' => __('Answered at'), - 'actions' => '' - ], $answered_questions), - heading(__('Ask the Heaven'), 2), - form([ - form_textarea('question', __('Your Question:'), ''), - form_submit('submit', __('Send')) - ], $ask_action) - ], true); -} diff --git a/resources/lang/de_DE/additional.po b/resources/lang/de_DE/additional.po index ece9a9d3..e9aef296 100644 --- a/resources/lang/de_DE/additional.po +++ b/resources/lang/de_DE/additional.po @@ -114,3 +114,12 @@ msgstr "FAQ Eintrag erfolgreich gelöscht." msgid "faq.edit.success" msgstr "FAQ Eintrag erfolgreich aktualisiert." + +msgid "question.delete.success" +msgstr "Frage erfolgreich gelöscht." + +msgid "question.add.success" +msgstr "Frage erstellt." + +msgid "question.edit.success" +msgstr "Frage erfolgreich bearbeitet." diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index a9ab1342..40fcf1f0 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -2957,3 +2957,18 @@ msgstr "Frage" msgid "faq.message" msgstr "Antwort" + +msgid "question.questions" +msgstr "Fragen" + +msgid "question.add" +msgstr "Fragen" + +msgid "question.edit" +msgstr "Frage bearbeiten" + +msgid "question.question" +msgstr "Frage" + +msgid "question.answer" +msgstr "Antwort" diff --git a/resources/lang/en_US/additional.po b/resources/lang/en_US/additional.po index bcf08ef7..020d0a7e 100644 --- a/resources/lang/en_US/additional.po +++ b/resources/lang/en_US/additional.po @@ -110,3 +110,12 @@ msgstr "FAQ entry successfully deleted." msgid "faq.edit.success" msgstr "FAQ entry successfully updated." + +msgid "question.delete.success" +msgstr "Question deleted successfully." + +msgid "question.add.success" +msgstr "Question added successfully." + +msgid "question.edit.success" +msgstr "Question updated successfully." diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index 4d54671e..c5b7c777 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -242,3 +242,18 @@ msgstr "Question" msgid "faq.message" msgstr "Answer" + +msgid "question.questions" +msgstr "Fragen" + +msgid "question.add" +msgstr "Ask" + +msgid "question.edit" +msgstr "Edit question" + +msgid "question.question" +msgstr "Question" + +msgid "question.answer" +msgstr "Answer" diff --git a/resources/views/macros/base.twig b/resources/views/macros/base.twig index 36d20e69..58285ba5 100644 --- a/resources/views/macros/base.twig +++ b/resources/views/macros/base.twig @@ -18,8 +18,8 @@ {% endmacro %} -{% macro button(label, url, type, size) %} - +{% macro button(label, url, type, size, title) %} + {{ label }} {% endmacro %} diff --git a/resources/views/pages/questions/edit.twig b/resources/views/pages/questions/edit.twig new file mode 100644 index 00000000..343ac12f --- /dev/null +++ b/resources/views/pages/questions/edit.twig @@ -0,0 +1,79 @@ +{% extends 'layouts/app.twig' %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %}{{ question and question.id ? __('question.edit') : __('question.add') }}{% endblock %} + +{% block content %} +
+

{{ block('title') }}

+ + {% include 'layouts/parts/messages.twig' %} + + {% if question and question.id %} +
+
+

+ {{ m.glyphicon('time') }} {{ question.updated_at.format(__('Y-m-d H:i')) }} + + {% if question.updated_at != question.created_at %} +  {{ __('form.updated') }} +
+ {{ m.glyphicon('time') }} {{ question.created_at.format(__('Y-m-d H:i')) }} + {% endif %} +

+
+
+ {% endif %} + +
+ {{ csrf() }} + +
+
+ {{ f.textarea('text', __('question.question'), {'required': true, 'rows': 10, 'value': question ? question.text : ''}) }} +
+
+ {% if is_admin|default(false) %} + {{ f.textarea('answer', __('question.answer'), {'required': true, 'rows': 10, 'value': question ? question.answer : ''}) }} + {% endif %} + + {{ f.submit() }} + + {% if is_admin|default(false) %} + {{ f.submit(m.glyphicon('eye-close'), {'name': 'preview', 'btn_type': 'info', 'title': __('form.preview')}) }} + + {% if question and question.id %} + {{ f.submit(m.glyphicon('trash'), {'name': 'delete', 'btn_type': 'danger', 'title': __('form.delete')}) }} + {% endif %} + {% endif %} +
+
+ + {% if question %} +
+
+

{{ __('form.preview') }}

+ +
+
+ {{ question.text|nl2br }} +
+
+
+ + {% if question.answer %} +
+
+
+ {{ question.answer|markdown }} +
+
+
+ {% endif %} +
+ {% endif %} + +
+
+{% endblock %} diff --git a/resources/views/pages/questions/overview.twig b/resources/views/pages/questions/overview.twig new file mode 100644 index 00000000..c7ab960a --- /dev/null +++ b/resources/views/pages/questions/overview.twig @@ -0,0 +1,83 @@ +{% extends 'layouts/app.twig' %} +{% import 'macros/base.twig' as m %} +{% import 'macros/form.twig' as f %} + +{% block title %} + {{ __('question.questions') }} +{% endblock %} + +{% block content %} +
+ + + {% include 'layouts/parts/messages.twig' %} + +
+ {% block row %} +
+ {% block questions %} + {% for question in questions %} +
+
+
+
+ {{ question.text|nl2br }} +
+ + +
+
+ {% if question.answer %} +
+
+
+ {{ question.answer|markdown }} +
+ +
+
+ {% endif %} +
+ {% endfor %} + {% endblock %} +
+ {% endblock %} +
+
+{% endblock %} diff --git a/src/Controllers/Admin/QuestionsController.php b/src/Controllers/Admin/QuestionsController.php new file mode 100644 index 00000000..f1ac77f7 --- /dev/null +++ b/src/Controllers/Admin/QuestionsController.php @@ -0,0 +1,177 @@ +auth = $auth; + $this->log = $log; + $this->question = $question; + $this->redirect = $redirector; + $this->response = $response; + } + + /** + * @return Response + */ + public function index(): Response + { + $questions = $this->question + ->orderBy('answer') + ->orderByDesc('created_at') + ->get(); + $this->cleanupModelNullValues($questions); + + return $this->response->withView( + 'pages/questions/overview.twig', + ['questions' => $questions, 'is_admin' => true] + $this->getNotifications() + ); + } + + /** + * @param Request $request + * + * @return Response + */ + public function delete(Request $request): Response + { + $data = $this->validate($request, [ + 'id' => 'required|int', + 'delete' => 'checked', + ]); + + $question = $this->question->findOrFail($data['id']); + $question->delete(); + + $this->log->info('Deleted question {question}', ['question' => $question->text]); + $this->addNotification('question.delete.success'); + + return $this->redirect->to('/admin/questions'); + } + + /** + * @param Request $request + * + * @return Response + */ + public function edit(Request $request): Response + { + $id = $request->getAttribute('id'); + $questions = $this->question->find($id); + + return $this->showEdit($questions); + } + + /** + * @param Request $request + * + * @return Response + */ + public function save(Request $request): Response + { + $id = $request->getAttribute('id'); + /** @var Question $question */ + $question = $this->question->findOrNew($id); + + $data = $this->validate($request, [ + 'text' => 'required', + 'answer' => 'required', + 'delete' => 'optional|checked', + 'preview' => 'optional|checked', + ]); + + if (!is_null($data['delete'])) { + $question->delete(); + + $this->log->info('Deleted question "{question}"', ['question' => $question->text]); + + $this->addNotification('question.delete.success'); + + return $this->redirect->to('/admin/questions'); + } + + $question->text = $data['text']; + $question->answer = $data['answer']; + $question->answered_at = Carbon::now(); + $question->answerer()->associate($this->auth->user()); + + if (!is_null($data['preview'])) { + return $this->showEdit($question); + } + + $question->save(); + + $this->log->info( + 'Updated questions "{text}": {answer}', + ['text' => $question->text, 'answer' => $question->answer] + ); + + $this->addNotification('question.edit.success'); + + return $this->redirect->to('/admin/questions'); + } + + /** + * @param Question|null $question + * + * @return Response + */ + protected function showEdit(?Question $question): Response + { + $this->cleanupModelNullValues($question); + + return $this->response->withView( + 'pages/questions/edit.twig', + ['question' => $question, 'is_admin' => true] + $this->getNotifications() + ); + } +} diff --git a/src/Controllers/QuestionsController.php b/src/Controllers/QuestionsController.php new file mode 100644 index 00000000..808ea360 --- /dev/null +++ b/src/Controllers/QuestionsController.php @@ -0,0 +1,145 @@ +auth = $auth; + $this->log = $log; + $this->question = $question; + $this->redirect = $redirect; + $this->response = $response; + } + + /** + * @return Response + */ + public function index(): Response + { + $questions = $this->question + ->whereUserId($this->auth->user()->id) + ->orderByDesc('created_at') + ->get(); + $this->cleanupModelNullValues($questions); + + return $this->response->withView( + 'pages/questions/overview.twig', + ['questions' => $questions] + $this->getNotifications() + ); + } + + /** + * @return Response + */ + public function add(): Response + { + return $this->response->withView( + 'pages/questions/edit.twig', + ['question' => null] + $this->getNotifications() + ); + } + + /** + * @param Request $request + * + * @return Response + */ + public function delete(Request $request): Response + { + $data = $this->validate( + $request, + [ + 'id' => 'int|required', + 'delete' => 'checked', + ] + ); + + $question = $this->question->findOrFail($data['id']); + if ($question->user->id != $this->auth->user()->id) { + throw new HttpForbidden(); + } + + $question->delete(); + + $this->log->info('Deleted own question {question}', ['question' => $question->text]); + $this->addNotification('question.delete.success'); + + return $this->redirect->to('/questions'); + } + + /** + * @param Request $request + * + * @return Response + */ + public function save(Request $request): Response + { + $data = $this->validate( + $request, + [ + 'text' => 'required', + ] + ); + + $question = new Question(); + $question->user()->associate($this->auth->user()); + $question->text = $data['text']; + $question->save(); + + $this->log->info( + 'Asked: {question}', + [ + 'question' => $question->text, + ] + ); + + $this->addNotification('question.add.success'); + + return $this->redirect->to('/questions'); + } +} diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index b841e353..9edba882 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -144,10 +144,6 @@ class LegacyMiddleware implements MiddlewareInterface $title = messages_title(); $content = user_messages(); return [$title, $content]; - case 'user_questions': - $title = questions_title(); - $content = user_questions(); - return [$title, $content]; case 'user_settings': $title = settings_title(); $content = user_settings(); @@ -156,10 +152,6 @@ class LegacyMiddleware implements MiddlewareInterface $title = register_title(); $content = guest_register(); return [$title, $content]; - case 'admin_questions': - $title = admin_questions_title(); - $content = admin_questions(); - return [$title, $content]; case 'admin_user': $title = admin_user_title(); $content = admin_user(); diff --git a/tests/Unit/Controllers/Admin/QuestionsControllerTest.php b/tests/Unit/Controllers/Admin/QuestionsControllerTest.php new file mode 100644 index 00000000..c23ad5e5 --- /dev/null +++ b/tests/Unit/Controllers/Admin/QuestionsControllerTest.php @@ -0,0 +1,267 @@ +response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/questions/overview.twig', $view); + $this->assertArrayHasKey('questions', $data); + $this->assertArrayHasKey('is_admin', $data); + + $this->assertEquals('Foobar?', $data['questions'][0]->text); + $this->assertTrue($data['is_admin']); + + return $this->response; + }); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->index(); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::delete + */ + public function testDeleteInvalidRequest() + { + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator($this->app->get(Validator::class)); + + $this->expectException(ValidationException::class); + $controller->delete($this->request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::delete + */ + public function testDeleteNotFound() + { + $this->request = $this->request->withParsedBody(['id' => 42, 'delete' => '1']); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator($this->app->get(Validator::class)); + + $this->expectException(ModelNotFoundException::class); + $controller->delete($this->request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::delete + */ + public function testDelete() + { + $this->request = $this->request->withParsedBody(['id' => 1, 'delete' => '1']); + $this->setExpects($this->response, 'redirectTo', ['http://localhost/admin/questions'], $this->response); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator($this->app->get(Validator::class)); + + $controller->delete($this->request); + + $this->assertCount(1, Question::all()); + $this->assertTrue($this->log->hasInfoThatContains('Deleted question')); + $this->assertHasNotification('question.delete.success'); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::edit + * @covers \Engelsystem\Controllers\Admin\QuestionsController::showEdit + */ + public function testEdit() + { + $this->request->attributes->set('id', 1); + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/questions/edit.twig', $view); + $this->assertArrayHasKey('question', $data); + $this->assertArrayHasKey('is_admin', $data); + + $this->assertEquals('Question?', $data['question']->text); + $this->assertEquals($this->user->id, $data['question']->user->id); + $this->assertTrue($data['is_admin']); + + return $this->response; + }); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + + $controller->edit($this->request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::save + */ + public function testSaveCreateInvalid() + { + $this->expectException(ValidationException::class); + + /** @var QuestionsController $controller */ + $controller = $this->app->make(QuestionsController::class); + $controller->setValidator(new Validator()); + $controller->save($this->request); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::save + */ + public function testSaveCreateEdit() + { + $this->request->attributes->set('id', 2); + $body = [ + 'text' => 'Foo?', + 'answer' => 'Bar!', + ]; + + $this->request = $this->request->withParsedBody($body); + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/admin/questions') + ->willReturn($this->response); + + /** @var QuestionsController $controller */ + $controller = $this->app->make(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->save($this->request); + + $this->assertTrue($this->log->hasInfoThatContains('Updated')); + $this->assertHasNotification('question.edit.success'); + + $question = Question::find(2); + $this->assertEquals('Foo?', $question->text); + $this->assertEquals('Bar!', $question->answer); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::save + */ + public function testSavePreview() + { + $this->request->attributes->set('id', 1); + $this->request = $this->request->withParsedBody([ + 'text' => 'Foo?', + 'answer' => 'Bar!', + 'preview' => '1', + ]); + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function ($view, $data) { + $this->assertEquals('pages/questions/edit.twig', $view); + + /** @var Question $question */ + $question = $data['question']; + // Contains new text + $this->assertEquals('Foo?', $question->text); + $this->assertEquals('Bar!', $question->answer); + + return $this->response; + }); + + /** @var QuestionsController $controller */ + $controller = $this->app->make(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->save($this->request); + + // Assert no changes + $question = Question::find(1); + $this->assertEquals('Question?', $question->text); + $this->assertEquals('Answer!', $question->answer); + } + + /** + * @covers \Engelsystem\Controllers\Admin\QuestionsController::save + */ + public function testSaveDelete() + { + $this->request->attributes->set('id', 1); + $this->request = $this->request->withParsedBody([ + 'text' => '.', + 'answer' => '.', + 'delete' => '1', + ]); + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/admin/questions') + ->willReturn($this->response); + + /** @var QuestionsController $controller */ + $controller = $this->app->make(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->save($this->request); + + $this->assertCount(1, Question::all()); + $this->assertTrue($this->log->hasInfoThatContains('Deleted question')); + $this->assertHasNotification('question.delete.success'); + } + + /** + * Setup environment + */ + public function setUp(): void + { + parent::setUp(); + + $this->auth = $this->createMock(Authenticator::class); + $this->app->instance(Authenticator::class, $this->auth); + + $this->app->bind(UrlGeneratorInterface::class, UrlGenerator::class); + + $this->user = new User([ + 'name' => 'foo', + 'password' => '', + 'email' => '', + 'api_key' => '', + 'last_login_at' => null, + ]); + $this->user->save(); + $this->setExpects($this->auth, 'user', null, $this->user, $this->any()); + + (new Question([ + 'user_id' => $this->user->id, + 'text' => 'Question?', + 'answerer_id' => $this->user->id, + 'answer' => 'Answer!', + 'answered_at' => new Carbon(), + ]))->save(); + + (new Question([ + 'user_id' => $this->user->id, + 'text' => 'Foobar?', + ]))->save(); + } +} diff --git a/tests/Unit/Controllers/ControllerTest.php b/tests/Unit/Controllers/ControllerTest.php index 0f107a3e..1d2f35eb 100644 --- a/tests/Unit/Controllers/ControllerTest.php +++ b/tests/Unit/Controllers/ControllerTest.php @@ -35,6 +35,16 @@ abstract class ControllerTest extends TestCase /** @var Session */ protected $session; + /** + * @param string $value + * @param string|null $message + */ + protected function assertHasNotification(string $value, string $message = null) + { + $messages = $this->session->get('messages', []); + $this->assertTrue(in_array($value, $messages), $message ?: 'Session does not contain message "' . $value . '"'); + } + /** * Setup environment */ @@ -56,6 +66,7 @@ abstract class ControllerTest extends TestCase $this->session = new Session(new MockArraySessionStorage()); $this->app->instance('session', $this->session); + $this->app->instance(Session::class, $this->session); $this->app->bind(UrlGeneratorInterface::class, UrlGenerator::class); diff --git a/tests/Unit/Controllers/QuestionsControllerTest.php b/tests/Unit/Controllers/QuestionsControllerTest.php new file mode 100644 index 00000000..e31d9bed --- /dev/null +++ b/tests/Unit/Controllers/QuestionsControllerTest.php @@ -0,0 +1,215 @@ +response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/questions/overview.twig', $view); + $this->assertArrayHasKey('questions', $data); + + $this->assertEquals('Lorem?', $data['questions'][0]->text); + + return $this->response; + }); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->index(); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::add + */ + public function testAdd() + { + $this->response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) { + $this->assertEquals('pages/questions/edit.twig', $view); + $this->assertArrayHasKey('question', $data); + $this->assertNull($data['question']); + + return $this->response; + }); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->add(); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::delete + */ + public function testDeleteNotFound() + { + $this->request = $this->request->withParsedBody([ + 'id' => '3', + 'delete' => '1', + ]); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $this->expectException(ModelNotFoundException::class); + $controller->delete($this->request); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::delete + */ + public function testDeleteNotOwn() + { + $otherUser = new User([ + 'name' => 'bar', + 'password' => '', + 'email' => '.', + 'api_key' => '', + 'last_login_at' => null, + ]); + $otherUser->save(); + (new Question([ + 'user_id' => $otherUser->id, + 'text' => 'Lorem?', + ]))->save(); + $this->request = $this->request->withParsedBody([ + 'id' => '3', + 'delete' => '1', + ]); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $this->expectException(HttpForbidden::class); + $controller->delete($this->request); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::delete + */ + public function testDelete() + { + $this->request = $this->request->withParsedBody([ + 'id' => '2', + 'delete' => '1', + ]); + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/questions') + ->willReturn($this->response); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->delete($this->request); + + $this->assertCount(1, Question::all()); + $this->assertTrue($this->log->hasInfoThatContains('Deleted own question')); + $this->assertHasNotification('question.delete.success'); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::save + */ + public function testSaveInvalid() + { + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $this->expectException(ValidationException::class); + $controller->save($this->request); + } + + /** + * @covers \Engelsystem\Controllers\QuestionsController::save + */ + public function testSave() + { + $this->request = $this->request->withParsedBody([ + 'text' => 'Some question?', + ]); + $this->response->expects($this->once()) + ->method('redirectTo') + ->with('http://localhost/questions') + ->willReturn($this->response); + + /** @var QuestionsController $controller */ + $controller = $this->app->get(QuestionsController::class); + $controller->setValidator(new Validator()); + + $controller->save($this->request); + + $this->assertCount(3, Question::all()); + $this->assertTrue($this->log->hasInfoThatContains('Asked')); + $this->assertHasNotification('question.add.success'); + } + + /** + * Setup environment + */ + public function setUp(): void + { + parent::setUp(); + + $this->auth = $this->createMock(Authenticator::class); + $this->app->instance(Authenticator::class, $this->auth); + + $this->app->bind(UrlGeneratorInterface::class, UrlGenerator::class); + + $this->user = new User([ + 'name' => 'foo', + 'password' => '', + 'email' => '', + 'api_key' => '', + 'last_login_at' => null, + ]); + $this->user->save(); + $this->setExpects($this->auth, 'user', null, $this->user, $this->any()); + + (new Question([ + 'user_id' => $this->user->id, + 'text' => 'Lorem?', + ]))->save(); + + (new Question([ + 'user_id' => $this->user->id, + 'text' => 'Foo?', + 'answerer_id' => $this->user->id, + 'answer' => 'Bar!', + 'answered_at' => new Carbon(), + ]))->save(); + } +}