diff --git a/src/app/Enums/FeedbackQuestionType.php b/src/app/Enums/FeedbackQuestionType.php new file mode 100644 index 0000000..7c7010c --- /dev/null +++ b/src/app/Enums/FeedbackQuestionType.php @@ -0,0 +1,12 @@ +middleware("permission:feedback.list", ["only" => ["index"]]); + $this->middleware("permission:feedback.retrieve", ["only" => ["show"]]); + + $this->service = $feedbackService; + } + + public function index(FeedbackRequest $request) + { + return view("feedback.index", [ + "feedbacks" => $this->service->getAllFeedback($request->authUser) + ]); + } + + public function show(FeedbackRequest $request) + { + return view("feedback.retrieve", [ + "feedback" => $request->feedback + ]); + } +} diff --git a/src/app/Http/Controllers/FeedbackCourseController.php b/src/app/Http/Controllers/FeedbackCourseController.php new file mode 100644 index 0000000..53eb27b --- /dev/null +++ b/src/app/Http/Controllers/FeedbackCourseController.php @@ -0,0 +1,85 @@ +middleware("role:" . Roles::STUDENT, ["only" => ["create", "store"]]); + $this->middleware("permission:feedback.list", ["only" => "index"]); + $this->service = $feedbackService; + } + + public function index(FeedbackCourseRequest $request) + { + return view("feedback.index", [ + "feedbacks" => $this->service->getAllFeedback($request->authUser, $request->course->id) + ]); + } + + public function create(FeedbackCourseRequest $request) + { + if ($this->service->isFeedbackComplete($request->authUser, $request->course->id)) { + abort(409, "You already completed feedback for this course"); + } + + return view("feedback.create", [ + "courseId" => $request->course->id, + "faculties" => $request->course->faculties, + "format" => $request->course->getFeedbackFormat() + ]); + } + + public function store(FeedbackCourseRequest $request) + { + /* + * { + * faculty1_id: { + * + * }, + * faculty_2_id: { + * + * } + * ... + * } + */ + $datas = $request->json("data"); + + if (!$this->service->verifyFaculties($datas, $request->course)) { + abort(400, "Invalid faculties"); + } + + if ($this->service->isFeedbackComplete($request->authUser, $request->course->id)) { + abort(409, "You already completed feedback for this course"); + } + + $feedbacks = array(); + foreach ($datas as $faculty_id => $facultyFeedback) { + try { + $feedbackData = $this->service->combineFeedbackWithFormat($facultyFeedback, $request->course); + } catch (IntendedException $e) { + abort(400, $e->getMessage()); + } + array_push($feedbacks, [ + // Eloquent isn't used for bulk insert so mutators won't work, so format manually + "data" => json_encode($feedbackData), + "faculty_id" => $faculty_id, + "course_id" => $request->course->id + ]); + } + // Bulk insert feedback and set feedback to complete + Feedback::insert($feedbacks); + $request->authUser->student->finishFeedback($request->course->id); + + return response("OK"); + } +} diff --git a/src/app/Http/Requests/FeedbackCourseRequest.php b/src/app/Http/Requests/FeedbackCourseRequest.php new file mode 100644 index 0000000..d007f07 --- /dev/null +++ b/src/app/Http/Requests/FeedbackCourseRequest.php @@ -0,0 +1,47 @@ +authUser = Auth::user(); + $courseId = $this->route()->parameter("course_id"); + $this->course = Course::findOrFail($courseId); + $this->courseCurriculum = Curriculum::where([ + "student_admission_id" => $this->authUser->student_admission_id, + "course_id" => $courseId + ])->first(); + + if ($this->route()->getName() == "faculties.courses.index") { + return true; + } + + return $this->authUser->student->hasCourse($courseId) + && !$this->courseCurriculum->is_feedback_completed + && $this->course->is_feedback_open; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/src/app/Http/Requests/FeedbackRequest.php b/src/app/Http/Requests/FeedbackRequest.php new file mode 100644 index 0000000..6b2c399 --- /dev/null +++ b/src/app/Http/Requests/FeedbackRequest.php @@ -0,0 +1,45 @@ +authUser = Auth::user(); + if ($this->route()->getName() == "feedbacks.show") { + $this->feedback = Feedback::findOrFail($this->route()->parameter("feedback")); + + $canViewEverything = $this->authUser->faculty?->isPrincipal() || $this->authUser->isAdmin(); + + $isHODAndSameDepartment = $this->authUser->faculty?->isHOD() && + $this->feedback->course->department_code == $this->authUser->faculty?->department_code; + + $isSameFaculty = $this->authUser->faculty_id == $this->feedback->faculty_id; + + return $canViewEverything || $isHODAndSameDepartment || $isSameFaculty; + } + return true; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + // + ]; + } +} diff --git a/src/app/Models/Course.php b/src/app/Models/Course.php index 5e6482f..3c7e7df 100644 --- a/src/app/Models/Course.php +++ b/src/app/Models/Course.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CourseTypes; +use App\Enums\FeedbackQuestionType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -18,11 +19,36 @@ class Course extends Model protected $guarded = [ 'semester', 'type', - 'active' => true + 'active' => true, + 'is_feedback_open' => false ]; protected $with = ["faculties"]; + static array $defaultFeedbackFormat = [ + [ + "question" => "Question 1", + "type" => FeedbackQuestionType::MCQ, + "options" => [ + ["string" => "option1", "score" => 1], + ["string" => "option2", "score" => 2], + ["string" => "option3", "score" => 3], + ["string" => "option4", "score" => 4], + ], + "required" => true + ], + [ + "question" => "Question 2", + "type" => FeedbackQuestionType::BOOLEAN, + "required" => true + ], + [ + "question" => "Question 3", + "type" => FeedbackQuestionType::TEXT, + "required" => true + ], + ]; + public function faculties(): BelongsToMany { return $this->belongsToMany(Faculty::class, "faculty_course"); @@ -73,4 +99,9 @@ public static function getBaseQuery() { return Course::with("subject")->where("active", true); } + + public function getFeedbackFormat(): array + { + return json_decode($this->feedback_format) ?? Course::$defaultFeedbackFormat; + } } diff --git a/src/app/Models/Department.php b/src/app/Models/Department.php index 0aacb9c..eb79dd9 100644 --- a/src/app/Models/Department.php +++ b/src/app/Models/Department.php @@ -30,7 +30,7 @@ public function classrooms() return $this->hasMany(Classroom::class); } - public function getHOD() + public function getHOD(): Faculty { return $this->faculties->first(function ($value, $_) { return $value->user->hasRole(Roles::HOD); diff --git a/src/app/Models/Feedback.php b/src/app/Models/Feedback.php new file mode 100644 index 0000000..ca2aea0 --- /dev/null +++ b/src/app/Models/Feedback.php @@ -0,0 +1,45 @@ + "json"]; + + public function setDataAttribute($data) + { + $this->attributes["data"] = json_encode($data); + } + + public function course(): BelongsTo + { + return $this->belongsTo(Course::class); + } + + public function faculty(): BelongsTo + { + return $this->belongsTo(Faculty::class); + } +} diff --git a/src/app/Models/Student.php b/src/app/Models/Student.php index ed01e18..4b00bb9 100644 --- a/src/app/Models/Student.php +++ b/src/app/Models/Student.php @@ -33,12 +33,37 @@ public function classroom() return $this->belongsTo(Classroom::class); } + public function curriculums() + { + return $this->hasMany(Curriculum::class); + } + public function semester() { return $this->classroom->semester; } + public function department() { return $this->classroom->department; } + + public function hasCourse($courseId): bool + { + $targetCourse = $this?->curriculums?->map(function ($curriculum) { + return $curriculum->course_id; + })?->filter(function ($studentCourseId) use ($courseId) { + return $studentCourseId == $courseId; + }); + + return $targetCourse != null && !$targetCourse->isEmpty(); + } + + public function finishFeedback($courseId) + { + echo json_encode($this->curriculums); + $targetCurriculum = $this->curriculums->firstWhere("course_id", $courseId); + $targetCurriculum->is_feedback_complete = true; + $targetCurriculum->save(); + } } diff --git a/src/app/Services/FeedbackService.php b/src/app/Services/FeedbackService.php new file mode 100644 index 0000000..f7055e9 --- /dev/null +++ b/src/app/Services/FeedbackService.php @@ -0,0 +1,96 @@ +where("course_id", $courseId); + } + if ($authUser->faculty?->isPrincipal() || $authUser->isAdmin()) { + return $query->get(); + } elseif ($authUser->faculty?->isHOD()) { + return $query->whereHas("course.classroom", function ($q) use ($authUser) { + $q->where("department_code", $authUser->faculty->department_code); + })->get(); + } else { + return $query->where("faculty_id", $authUser->faculty_id)->get(); + } + } + + private function validateFeedbackQuestionOrThrow($format, $answer): void + { + if (!$format["required"] && $answer === "") { + return; + } + switch ($format["type"]) { + case FeedbackQuestionType::MCQ: + if (!is_numeric($answer) || !in_array($answer, [1, 2, 3, 4])) { + throw new IntendedException("Invalid Feedback"); + } + break; + case FeedbackQuestionType::BOOLEAN: + if (!is_numeric($answer) || !in_array($answer, [0, 1])) { + throw new IntendedException("Invalid Feedback"); + } + break; + case FeedbackQuestionType::TEXT: + if ($answer === "") { + throw new IntendedException("Invalid Feedback"); + } + break; + } + } + + public function combineFeedbackWithFormat(array $feedbackFromUser, Course $course): array + { + $format = $course->getFeedbackFormat(); + $feedbackResult = array(); + if (count($feedbackFromUser) > count($format)) { + throw new IntendedException("Invalid Feedback"); + } + for ($i = 0; $i < count($feedbackFromUser); $i++) { + $this->validateFeedbackQuestionOrThrow($format[$i], $feedbackFromUser[$i]); + array_push($feedbackResult, [ + "question" => $format[$i]["question"], + "type" => $format[$i]["type"], + "answer" => $feedbackFromUser[$i] + ]); + // Add option's text too if mcq + if ($format[$i]["type"] == FeedbackQuestionType::MCQ) { + $feedbackResult[$i]["answer_text"] = array_filter( + $format[$i]["options"], + function ($option) use ($feedbackFromUser, $i) { + return $option["score"] == $feedbackFromUser[$i]; + } + ); + } + } + return $feedbackResult; + } + + public function verifyFaculties($data, $course): bool + { + // Check if faculty_ids are valid + $userFacultyIds = array_keys($data); + + $validFacultyIds = $course->faculties->map(function ($faculty) { + return $faculty->id; + })->toArray(); + + return count(array_diff($userFacultyIds, $validFacultyIds)) == 0; + } + + public function isFeedbackComplete($authUser, $courseId) + { + return $authUser->student->curriculums->firstWhere("course_id", $courseId)->is_feedback_complete; + } +} diff --git a/src/database/factories/FeedbackFactory.php b/src/database/factories/FeedbackFactory.php new file mode 100644 index 0000000..fe37aa6 --- /dev/null +++ b/src/database/factories/FeedbackFactory.php @@ -0,0 +1,32 @@ + [ + 3, + 0, + "testing the system" + ] + ]; + } +} diff --git a/src/database/migrations/2021_05_29_145000_create_courses_table.php b/src/database/migrations/2021_05_29_145000_create_courses_table.php index 37b94f8..6c8d44c 100644 --- a/src/database/migrations/2021_05_29_145000_create_courses_table.php +++ b/src/database/migrations/2021_05_29_145000_create_courses_table.php @@ -29,6 +29,9 @@ public function up() $table->foreign('classroom_id') ->references('id') ->on('classrooms'); + + $table->boolean("is_feedback_open")->default(false); + $table->json("feedback_format")->nullable(); }); } diff --git a/src/database/migrations/2021_07_01_141604_create_feedbacks_table.php b/src/database/migrations/2021_07_01_141604_create_feedbacks_table.php new file mode 100644 index 0000000..e48ffa8 --- /dev/null +++ b/src/database/migrations/2021_07_01_141604_create_feedbacks_table.php @@ -0,0 +1,42 @@ +id(); + $table->integer("course_id"); + $table->foreign("course_id") + ->references("id") + ->on("courses") + ->onDelete("cascade"); + + $table->string("faculty_id"); + $table->foreign("faculty_id") + ->references("id") + ->on("faculties") + ->onDelete("cascade"); + + $table->json("data"); + }); + } + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('feedbacks'); + } +} diff --git a/src/database/seeders/CourseSeeder.php b/src/database/seeders/CourseSeeder.php index ee225f9..1960012 100644 --- a/src/database/seeders/CourseSeeder.php +++ b/src/database/seeders/CourseSeeder.php @@ -23,6 +23,7 @@ public function run() $s1classroom = Classroom::where(["semester" => 1, "department_code" => "CSE"])->first(); $course1 = Course::create([ + 'is_feedback_open' => true, 'subject_code' => $subject1->code, 'type' => CourseTypes::REGULAR, 'semester' => 1, @@ -30,6 +31,7 @@ public function run() ]); $course2 = Course::create([ + 'is_feedback_open' => true, 'subject_code' => $subject2->code, 'type' => CourseTypes::REGULAR, 'semester' => 1, diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php index 2f74828..2b9a1c5 100644 --- a/src/database/seeders/DatabaseSeeder.php +++ b/src/database/seeders/DatabaseSeeder.php @@ -25,5 +25,6 @@ public function run() $this->call(CourseSeeder::class); $this->call(CurriculumSeeder::class); $this->call(AttendanceSeeder::class); + $this->call(FeedbackSeeder::class); } } diff --git a/src/database/seeders/FeedbackSeeder.php b/src/database/seeders/FeedbackSeeder.php new file mode 100644 index 0000000..2d3fa33 --- /dev/null +++ b/src/database/seeders/FeedbackSeeder.php @@ -0,0 +1,23 @@ + "hod_cse", + "course_id" => 1, + ])->create(); + } +} diff --git a/src/database/seeders/PermissionTableSeeder.php b/src/database/seeders/PermissionTableSeeder.php index 8762604..7c0782e 100644 --- a/src/database/seeders/PermissionTableSeeder.php +++ b/src/database/seeders/PermissionTableSeeder.php @@ -37,6 +37,8 @@ public function run() 'faculty.update', 'faculty.delete', 'faculty.*', + 'feedback.list', + 'feedback.retrieve', ]; foreach ($permissions as $permission) { diff --git a/src/database/seeders/RoleSeeder.php b/src/database/seeders/RoleSeeder.php index 92fae91..1696175 100644 --- a/src/database/seeders/RoleSeeder.php +++ b/src/database/seeders/RoleSeeder.php @@ -32,19 +32,25 @@ public function run() "delete" => ["faculty.delete"], "all" => "faculty.*" ]; + $feedbackPermissions = [ + "view" => ["feedback.retrieve", "feedback.list"], + ]; $roleHOD = Role::create(['name' => Roles::HOD]); $roleFaculty = Role::create(['name' => Roles::FACULTY]); $roleAdvisor = Role::create(["name" => Roles::STAFF_ADVISOR]); $rolePrincipal = Role::create(["name" => Roles::PRINCIPAL]); + $roleFaculty->syncPermissions( + $attendancePermissions["all"], + ["faculty.retrieve", "faculty.update"], + $feedbackPermissions["view"] + ); $roleHOD->syncPermissions( - $attendancePermissions["view"], - array_merge(["faculty.create", "faculty.delete"], $facultyPermissions["view"]), + ["faculty.create", "faculty.delete"], + $facultyPermissions["view"] ); - $roleFaculty->syncPermissions($attendancePermissions["all"], ["faculty.retrieve", "faculty.update"]); - $roleAdvisor->syncPermissions($attendancePermissions["view"]); - $rolePrincipal->syncPermissions($attendancePermissions["view"], $facultyPermissions["view"]); + $rolePrincipal->syncPermissions($facultyPermissions["view"]); $roleStudent = Role::create(['name' => Roles::STUDENT]); $roleStudent->syncPermissions("attendance.retrieve"); diff --git a/src/reset_db.sh b/src/reset_db.sh index e6d3ba7..f1f8553 100644 --- a/src/reset_db.sh +++ b/src/reset_db.sh @@ -1,2 +1,2 @@ -php artisan migrate:refresh +php artisan migrate:fresh sh ./initialsetup.sh diff --git a/src/resources/views/feedback/create.blade.php b/src/resources/views/feedback/create.blade.php index 1be8138..936620e 100644 --- a/src/resources/views/feedback/create.blade.php +++ b/src/resources/views/feedback/create.blade.php @@ -1,5 +1,59 @@ @extends("layouts.layout") +@section("head") + +@endsection + @section("content") + @foreach($faculties as $faculty) +

{{ $faculty->name }}

+ @foreach($format as $index => $question) + + @endforeach + @endforeach + @endsection diff --git a/src/resources/views/feedback/retrieve.blade.php b/src/resources/views/feedback/retrieve.blade.php new file mode 100644 index 0000000..a0ccb8a --- /dev/null +++ b/src/resources/views/feedback/retrieve.blade.php @@ -0,0 +1 @@ +@extends("layouts.layout") diff --git a/src/routes/web.php b/src/routes/web.php index 3da1bcc..c23a5cc 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -9,7 +9,8 @@ use App\Http\Controllers\RequestController; use App\Http\Controllers\PhotoUploadController; use App\Http\Controllers\TestRequestController; - +use App\Http\Controllers\FeedbackController; +use App\Http\Controllers\FeedbackCourseController; /* |-------------------------------------------------------------------------- | Web Routes @@ -37,6 +38,15 @@ ] ]); Route::resource("testrequest", TestRequestController::class); + Route::resource("feedbacks", FeedbackController::class); + Route::resource("feedbacks/courses/{course_id}", FeedbackCourseController::class, [ + "only" => ["index", "create", "store"], + "names" => [ + "index" => "feedbacks.courses.index", + "create" => "feedbacks.create", + "store" => "feedbacks.store" + ] + ]); }); Route::group(["middleware" => ["guest"]], function () { diff --git a/src/tests/Feature/FeedbackTest.php b/src/tests/Feature/FeedbackTest.php new file mode 100644 index 0000000..e4fb9c6 --- /dev/null +++ b/src/tests/Feature/FeedbackTest.php @@ -0,0 +1,123 @@ +assertLoginRequired(route("feedbacks.index")); + $this->assertUsersOnEndpoint( + route("feedbacks.index"), + "get", + [ + Roles::STUDENT => 403, + Roles::OFFICE => 403, + Roles::ADMIN => 200, + Roles::FACULTY => 200 + ] + ); + } + + public function testFeedbackRetrieve() + { + $feedbacks = Feedback::with("faculty.user")->get(); + foreach ($feedbacks as $feedback) { + $url = route("feedbacks.show", $feedback->id); + $this->assertUsersOnEndpoint( + $url, + "get", + [ + Roles::ADMIN => 200, + Roles::PRINCIPAL => 200 + ] + ); + + $otherFaculties = array_filter($this->users[Roles::FACULTY], function ($user) use ($feedback) { + return $user->faculty_id != $feedback->faculty_id; + }); + $otherHODs = array_filter($this->users[Roles::HOD], function ($user) use ($feedback) { + return $user->faculty_id != $feedback->faculty_id; + }); + $unauthorisedUsers = array_merge($otherFaculties, $otherHODs); + foreach ($unauthorisedUsers as $unauthorisedUser) { + $this->actingAs($unauthorisedUser)->get($url) + ->assertStatus(403); + } + + $req = $this->actingAs($feedback->faculty->user)->get($url) + ->assertStatus(200); + $req->assertStatus(200); + $this->actingAs($feedback->faculty->department->getHOD()->user)->get($url) + ->assertStatus(200); + } + } + + public function testCourseRetrieve() + { + + } + + public function testCreate() + { + $courses = Course::with("faculties", "curriculums.student") + ->where("is_feedback_open", true) + ->get(); + $this->assertLoginRequired(route("feedbacks.create", $courses->first()->id)); + foreach ($courses as $course) { + $students = $course->curriculums->map(function ($curriculum) { + return $curriculum->student; + }); + $otherStudents = Student::whereHas("curriculums", function ($q) use ($course) { + $q->where("course_id", "!=", $course->id); + }); + $formUrl = route("feedbacks.create", $course->id); + $storeUrl = route("feedbacks.store", $course->id); + $this->assertUsersOnEndpoint( + $formUrl, + "get", + [ + Roles::OFFICE => 403, + Roles::ADMIN => 403, + Roles::FACULTY => 403, + Roles::HOD => 403, + Roles::PRINCIPAL => 403 + ] + ); + foreach ($students as $student) { + $this->actingAs($student->user)->get($formUrl) + ->assertStatus(200); + $data = []; + foreach ($course->faculties as $faculty) { + $data[$faculty->id] = Feedback::$testFeedback; + } + + $this->actingAs($student->user)->json("post", $storeUrl, ["data" => $data]) + ->assertStatus(200); + + $targetCurriculum = Curriculum::query() + ->where([ + "student_admission_id" => $student->admission_id, + "course_id" => $course->id + ])->get(); + + $this->assertTrue($targetCurriculum->is_feedback_complete); + + // Can't do feedback more than once + $this->actingAs($student->user)->json("post", $storeUrl, ["data" => $data]) + ->assertStatus(409); + } + foreach ($otherStudents as $student) { + $this->actingAs($student->user)->get($formUrl) + ->assertStatus(403); + } + } + } +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 0d95d0d..46d891e 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -50,7 +50,7 @@ private function getHighestRole($roles) } elseif (in_array(Roles::STAFF_ADVISOR, $role_names)) { return Roles::STAFF_ADVISOR; } else { - // Faculty, Student + // Faculty, Student, Office return array_pop($role_names); } }