diff --git a/app/Http/Controllers/CourseTrackController.php b/app/Http/Controllers/CourseTrackController.php index ba379e0c..075f606e 100644 --- a/app/Http/Controllers/CourseTrackController.php +++ b/app/Http/Controllers/CourseTrackController.php @@ -5,11 +5,28 @@ use App\Models\Course; use App\Models\CourseTrack; use App\Models\Task; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\View\View; class CourseTrackController extends Controller { + + public function index(Course $course) : View + { + $tracks = $course->tracks()->get(); + + $tracks->map(function(CourseTrack $track) { + $track->depth = $track->getDepthAttribute(); + }); + + return view('courses.tracks.index', [ + 'course' => $course, + 'tracks' => $tracks, + ]); + } + public function show(Course $course, CourseTrack $track) : View { $tasks = $course->tasks()->where('track_id', $track->id)->where('is_visible', true)->get()->map(fn(Task $task) => [ @@ -32,4 +49,143 @@ public function show(Course $course, CourseTrack $track) : View ], ]); } + + public function create(Request $request, Course $course) : View + { + Log::info("Create course track"); + + $parent = $course->tracks()->where('id', $request->query('parent'))->first(); + + return view('courses.tracks.create', [ + 'course' => $course, + 'parent' => $parent, + ]); + } + + public function store(Request $request, Course $course) : RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255|min:1', + 'description' => 'nullable|string', + 'parent' => 'nullable|exists:course_tracks,id', + ]); + + /** @var CourseTrack $track */ + + $track = $course->tracks()->make([ + 'name' => $validated['name'], + 'description' => $validated['description'], + ]); + + if (array_key_exists('parent', $validated) && $validated['parent'] != null) + { + $parent = $course->tracks()->where('id', $validated['parent'])->first(); + if ($parent == null) + { + flash()->addError("Parent track does not exist!"); + + return redirect()->route('courses.manage.tracks.index', $course); + } + + $track->parent()->associate($validated['parent']); + } + + $track->save(); + + flash()->addSuccess("Track created successfully"); + + return redirect()->route('courses.manage.tracks.index', [$course, $track]); + } + + public function destroy(Course $course, CourseTrack $track) : RedirectResponse + { + if ($track->children()->count() > 0) + { + flash()->addError("Track has sub tracks, remove them first!"); + + return redirect()->route('courses.manage.tracks.index', [$course]); + } + + Log::info("Deleting course track $track->id"); + + $track->tasks()->each(function(Task $task) { + $task->track()->dissociate(); + $task->save(); + }); + + $track->delete(); + + flash()->addSuccess("Track deleted successfully"); + + return redirect()->route('courses.manage.tracks.index', $course); + } + + public function assign(Course $course, CourseTrack $track) : View + { + $tasks = $course->tasks()->where('track_id', null)->get(); + + return view('courses.tracks.assign-tasks', [ + 'course' => $course, + 'track' => $track, + 'tasks' => $tasks, + ]); + } + + public function doAssign(Request $request, Course $course, CourseTrack $track) : RedirectResponse + { + if ($course->id != $track->course_id) + { + abort(403); + } + + $validated = $request->validate([ + 'tasks' => 'required|array', + 'tasks.*' => 'exists:tasks,id', + ]); + + $selectedTasks = $course->tasks()->whereIn('id', $validated['tasks'])->getQuery(); + if ($selectedTasks->count() != count($validated['tasks'])) + { + flash()->addError("Some tasks do not exist"); + + return redirect()->route('courses.manage.tracks.assign', [$course, $track]); + } + + if ($selectedTasks->clone()->where('track_id', '!=', null)->count() > 0) + { + flash()->addError("Some tasks are already assigned to a track"); + + return redirect()->route('courses.manage.tracks.assign', [$course, $track]); + } + + + $selectedTasks->get()->each(function (Task $task) use ($track) { + $task->track()->associate($track); + $task->save(); + }); + + flash()->addSuccess("Tasks assigned to track successfully"); + + return redirect()->route('courses.manage.tracks.index', $course); + } + + public function unassign(Course $course, CourseTrack $track, Task $task) : RedirectResponse + { + if ($course->id != $track->course_id) + { + abort(403); + } + + if ($task->track_id != $track->id) + { + abort(403); + } + + $task->track()->dissociate(); + $task->save(); + + flash()->addSuccess("Task unassigned from track successfully"); + + return redirect()->route('courses.manage.tracks.assign', [$course, $track]); + } } diff --git a/app/Models/CourseTrack.php b/app/Models/CourseTrack.php index 62fb26fa..c2f918b7 100644 --- a/app/Models/CourseTrack.php +++ b/app/Models/CourseTrack.php @@ -2,6 +2,7 @@ namespace App\Models; +use Database\Factories\CourseTrackFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,8 +11,12 @@ use Illuminate\Support\Facades\DB; /** + * @property-read int $id + * @property int $course_id * @property-read CourseTrack|null $parent * @property-read CourseTrack[]|Collection $immediateChildren + * @method static CourseTrackFactory factory() + * @mixin \Eloquent */ class CourseTrack extends Model { @@ -140,4 +145,9 @@ public function rootChildrenNotInPath(bool $withChildren = true): Collection return $remaining->whereNotIn('id', $this->children()->pluck('id')); } + + public function getDepthAttribute(): int + { + return $this->path()->count(); + } } diff --git a/app/Models/Task.php b/app/Models/Task.php index b6c320d0..bd9e024d 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -57,6 +57,7 @@ * @property string $name * @static Task findOrFail($id, $columns = []) { * @property SubTaskCollection $sub_tasks + * @property-read int $track_id * @property-read CourseTrack|null $track * @property-read SurveyTask|null $pivot * @property-read bool $hasEnded diff --git a/database/factories/CourseTrackFactory.php b/database/factories/CourseTrackFactory.php index 3d50344e..1fc7c76f 100644 --- a/database/factories/CourseTrackFactory.php +++ b/database/factories/CourseTrackFactory.php @@ -17,7 +17,8 @@ class CourseTrackFactory extends Factory public function definition() { return [ - // + 'name' => $this->faker->sentence(3), ]; } + } diff --git a/resources/css/app.css b/resources/css/app.css index 61bd5048..b32c5958 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -106,3 +106,7 @@ @apply bg-red-700 hover:bg-red-800 text-white py-2 px-2 rounded transition-colors text-center } } + +.max-h-fit { + max-height: fit-content; +} diff --git a/resources/views/courses/manage/partials/sidebar.blade.php b/resources/views/courses/manage/partials/sidebar.blade.php index db4a0db8..4ab6b274 100644 --- a/resources/views/courses/manage/partials/sidebar.blade.php +++ b/resources/views/courses/manage/partials/sidebar.blade.php @@ -7,8 +7,11 @@ - - + + @@ -20,23 +23,41 @@ class="h-5 w-5 text-gray-400"> + + + + + + + - - - - + + + + - - + + - - - + + + diff --git a/resources/views/courses/tracks/assign-tasks.blade.php b/resources/views/courses/tracks/assign-tasks.blade.php new file mode 100644 index 00000000..fb6a7893 --- /dev/null +++ b/resources/views/courses/tracks/assign-tasks.blade.php @@ -0,0 +1,59 @@ +@extends('master') + +@section('content') +
+
+ @csrf +
+

Assigning tasks to track "{{$track->name}}"

+

Hold ctrl or cmd to do a multi select.

+
+ + +
+ @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+ +
+

Currently Assigned Tasks

+ @if($track->tasks->isEmpty()) +

No tasks are currently assigned to this track.

+ @else + + @endif +
+
+@endsection diff --git a/resources/views/courses/tracks/create.blade.php b/resources/views/courses/tracks/create.blade.php new file mode 100644 index 00000000..8a52434f --- /dev/null +++ b/resources/views/courses/tracks/create.blade.php @@ -0,0 +1,43 @@ +@extends('master') + +@section('content') +
+
+ @csrf +
+

Creating course track for course "{{$course->name}}"

+ @isset($parent) +

Parent track: {{ $parent->name }}

+ @endisset +
+ + +
+ + + + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+
+
+@endsection diff --git a/resources/views/courses/tracks/index.blade.php b/resources/views/courses/tracks/index.blade.php new file mode 100644 index 00000000..b49141f5 --- /dev/null +++ b/resources/views/courses/tracks/index.blade.php @@ -0,0 +1,60 @@ +@extends('courses.manage.master') + +@section('manageContent') +
+
+
+

Course tracks

+

Create tracks to organize your course activities.

+
+ + Create track +
+
+ @if($tracks->isEmpty()) +

No tracks have been created for this course.

+ @else + + @endif +
+@endsection diff --git a/resources/views/courses/tracks/show.blade.php b/resources/views/courses/tracks/show.blade.php index 926e5cfa..4c9ca127 100644 --- a/resources/views/courses/tracks/show.blade.php +++ b/resources/views/courses/tracks/show.blade.php @@ -64,6 +64,14 @@ class="bg-lime-green-700 w-full flex rounded-lg text-center items-center text-wh @endforeach
+ @can('manage', $course) +
+ Assign tasks + Create subtrack +
+ @endcan
diff --git a/routes/web/course.php b/routes/web/course.php index 0adb2e62..48ea1eed 100644 --- a/routes/web/course.php +++ b/routes/web/course.php @@ -41,9 +41,7 @@ Route::post('{task}/create-project', [TaskController::class, 'doCreateProject'])->name('createProject'); }); - Route::prefix('tracks')->as('tracks.')->controller(CourseTrackController::class)->group(function() { - Route::get('{track}', 'show')->name('show'); - }); + Route::get('tracks/{track}', [CourseTrackController::class, 'show'])->name('tracks.show'); Route::group(['prefix' => 'groups', 'as' => 'groups.'], function() { Route::get('/', [GroupController::class, 'index'])->name('index'); @@ -99,5 +97,15 @@ Route::get('tasks/{task}', [GradingController::class, 'taskInfo'])->name('task-info'); Route::post('{grade}/set-selected', [GradingController::class, 'setSelected'])->name('set-selected'); }); + + Route::prefix('tracks')->as('tracks.')->controller(CourseTrackController::class)->middleware('can:manage,course')->group(function() { + Route::get('/', 'index')->name('index'); + Route::get('create', 'create')->name('create'); + Route::post('create', 'store')->name('store'); + Route::delete('{track}', 'destroy')->name('destroy'); + Route::get("{track}/assign", 'assign')->name('assign'); + Route::post("{track}/assign", 'doAssign')->name('doAssign'); + Route::delete('{track}/assign/{task}', 'unassign')->name('unassign'); + }); }); }); diff --git a/tests/Feature/Course/Manage/Track/AssignTasksToTrackTest.php b/tests/Feature/Course/Manage/Track/AssignTasksToTrackTest.php new file mode 100644 index 00000000..c183fa54 --- /dev/null +++ b/tests/Feature/Course/Manage/Track/AssignTasksToTrackTest.php @@ -0,0 +1,101 @@ +course = Course::factory()->create(); + $this->track = CourseTrack::factory()->for($this->course)->create(); + $this->task = Task::factory()->for($this->course)->create(); + + $this->user = UserFactory::new()->hasAttached($this->course)->create(); + $this->professor = UserFactory::new()->hasAttached($this->course, ['role' => 'teacher'])->create(); +}); + +it('does not allow students to access the task assignment page', function() { + actingAs($this->user); + get(route('courses.manage.tracks.assign', [$this->course, $this->track])) + ->assertStatus(403); +}); + +it('allows professors to access the task assignment page', function() { + actingAs($this->professor); + get(route('courses.manage.tracks.assign', [$this->course, $this->track])) + ->assertStatus(200); +}); + +it('does not allow students to post to the task assignment route', function() { + actingAs($this->user); + post(route('courses.manage.tracks.assign', [$this->course, $this->track])) + ->assertStatus(403); +}); + +it('validates the tasks field', function() { + actingAs($this->professor); + post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => '', + ])->assertSessionHasErrors(['tasks']); + + post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => ['Hello World'], + ])->assertSessionHasErrors(['tasks.*']); + + post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => [$this->task->id], + ])->assertSessionHasNoErrors(); +}); + +it('allows a professor to assign tasks to a track', function() { + actingAs($this->professor); + post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => [$this->task->id], + ])->assertSessionHasNoErrors() + ->assertRedirectToRoute('courses.manage.tracks.index', [$this->course]); + + $this->assertDatabaseHas('tasks', [ + 'id' => $this->task->id, + 'track_id' => $this->track->id, + ]); +}); + +it('does not allow a professor to assign a task within the course to a track that does not belong to the course', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + $track = CourseTrack::factory()->for($course)->create(); + post(route('courses.manage.tracks.assign', [$this->course, $track]), [ + 'tasks' => [$this->task->id], + ])->assertStatus(403); + + post(route('courses.manage.tracks.assign', [$course, $this->track]), [ + 'tasks' => [$this->task->id], + ])->assertStatus(403); +}); + +it('does not allow a professor to assign a task that is not part of the course to a track', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + $task = Task::factory()->for($course)->create(); + $res = post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => [$task->id], + ])->assertRedirectToRoute('courses.manage.tracks.assign', [$this->course, $this->track]); +}); + +it('does not allow a professor to assign a task that is already assigned to a track', function() { + actingAs($this->professor); + $this->task->track()->associate($this->track); + $this->task->save(); + + post(route('courses.manage.tracks.assign', [$this->course, $this->track]), [ + 'tasks' => [$this->task->id], + ])->assertRedirectToRoute('courses.manage.tracks.assign', [$this->course, $this->track]); +}); + + diff --git a/tests/Feature/Course/Manage/Track/CreateTrackTest.php b/tests/Feature/Course/Manage/Track/CreateTrackTest.php new file mode 100644 index 00000000..96eb5440 --- /dev/null +++ b/tests/Feature/Course/Manage/Track/CreateTrackTest.php @@ -0,0 +1,128 @@ +course = Course::factory()->create(); + + $this->user = UserFactory::new()->hasAttached($this->course)->create(); + $this->professor = UserFactory::new()->hasAttached($this->course, ['role' => 'teacher'])->create(); +}); + +it('allows a professor to access the create a track page', function() { + actingAs($this->professor); + get("/courses/{$this->course->id}/manage/tracks/create") + ->assertStatus(200); +}); + +it('does not allow a student to access the create a track page', function() { + actingAs($this->user); + get("/courses/{$this->course->id}/manage/tracks/create") + ->assertStatus(403); +}); + +it('does not allow a student to post to the create track route', function() { + actingAs($this->user); + post("/courses/{$this->course->id}/manage/tracks/create") + ->assertStatus(403); +}); + +it('validates the name field', function() { + actingAs($this->professor); + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => '', + ])->assertSessionHasErrors(['name']); + + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertSessionHasNoErrors(); +}); + +it('allows an empty description', function() { + actingAs($this->professor); + post(route('courses.manage.tracks.create', [$this->course]), [ + 'name' => 'Hello World', + 'description' => '', + ])->assertSessionHasNoErrors(); + + post(route('courses.manage.tracks.create', [$this->course]), [ + 'name' => 'Hello World', + ])->assertSessionHasNoErrors(); +}); + +it('creates a track', function() { + actingAs($this->professor); + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('course_tracks', [ + 'name' => 'Hello World', + 'description' => 'abcd', + ]); +}); + +it('creates a track with a parent', function() { + actingAs($this->professor); + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertSessionHasNoErrors(); + + $track = CourseTrack::where('name', 'Hello World')->first(); + + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'With Parent', + 'description' => 'abcd', + 'parent' => $track->id, + ])->assertSessionHasNoErrors(); + + $this->assertDatabaseHas('course_tracks', [ + 'name' => 'With Parent', + 'description' => 'abcd', + 'parent_id' => $track->id, + ]); +}); + +it('does not allow creating a track with a parent that does not exist', function() { + actingAs($this->professor); + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertSessionHasNoErrors(); + + post("/courses/{$this->course->id}/manage/tracks/create", [ + 'name' => 'With Parent', + 'description' => 'abcd', + 'parent' => 1234, + ])->assertSessionHasErrors(['parent']); +}); + +it('does not allow creating a track for a course that does not exist', function() { + actingAs($this->professor); + post("/courses/1234/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertStatus(404); +}); + +it('does not allow creating a track for a course that the user is not attached to', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + post("/courses/{$course->id}/manage/tracks/create", [ + 'name' => 'Hello World', + 'description' => 'abcd', + ])->assertStatus(403); +}); diff --git a/tests/Feature/Course/Manage/Track/DeleteTrackTest.php b/tests/Feature/Course/Manage/Track/DeleteTrackTest.php new file mode 100644 index 00000000..9ce4e2d0 --- /dev/null +++ b/tests/Feature/Course/Manage/Track/DeleteTrackTest.php @@ -0,0 +1,75 @@ +course = Course::factory()->create(); + $this->track = CourseTrack::factory()->for($this->course)->create(); + + $this->user = UserFactory::new()->hasAttached($this->course)->create(); + $this->professor = UserFactory::new()->hasAttached($this->course, ['role' => 'teacher'])->create(); +}); + +it('does not allow a student to delete a track', function() { + actingAs($this->user); + delete("/courses/{$this->course->id}/manage/tracks/{$this->track->id}") + ->assertStatus(403); +}); + +it('does allow a professor to delete a track', function() { + actingAs($this->professor); + delete("/courses/{$this->course->id}/manage/tracks/{$this->track->id}") + ->assertRedirect(route('courses.manage.tracks.index', $this->course)); +}); + +it('returns 404 when trying to delete a track that does not exist', function() { + actingAs($this->professor); + delete("/courses/{$this->course->id}/manage/tracks/12341") + ->assertStatus(404); +}); + +it('returns 403 when trying to delete a track from another course', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + $track = CourseTrack::factory()->for($course)->create(); + delete("/courses/{$course->id}/manage/tracks/{$track->id}") + ->assertStatus(403); +}); + +it('should unassign all tasks from the track', function() { + actingAs($this->professor); + $task = Task::factory()->for($this->course)->create(); + $task->track()->associate($this->track); + $task->save(); + $this->assertDatabaseHas('tasks', [ + 'track_id' => $this->track->id, + ]); + + delete("/courses/{$this->course->id}/manage/tracks/{$this->track->id}") + ->assertRedirect(route('courses.manage.tracks.index', $this->course)); + + $this->assertDatabaseMissing('tasks', [ + 'track_id' => $this->track->id, + ]); +}); + +it('should return 400 when trying to delete a track with sub tracks', function() { + actingAs($this->professor); + $subTrack = CourseTrack::factory()->for($this->course)->create(['parent_id' => $this->track->id]); + delete("/courses/{$this->course->id}/manage/tracks/{$this->track->id}") + ->assertRedirect(route('courses.manage.tracks.index', $this->course)); + + $this->assertDatabaseHas('course_tracks', [ + 'id' => $this->track->id, + ]); +}); + + diff --git a/tests/Feature/Course/Manage/Track/UnassignTaskFromTrackTest.php b/tests/Feature/Course/Manage/Track/UnassignTaskFromTrackTest.php new file mode 100644 index 00000000..347c8aed --- /dev/null +++ b/tests/Feature/Course/Manage/Track/UnassignTaskFromTrackTest.php @@ -0,0 +1,53 @@ +course = Course::factory()->create(); + $this->track = CourseTrack::factory()->for($this->course)->create(); + $this->task = Task::factory()->for($this->course)->create(); + + $this->user = UserFactory::new()->hasAttached($this->course)->create(); + $this->professor = UserFactory::new()->hasAttached($this->course, ['role' => 'teacher'])->create(); +}); + +it('does not allow students to hit the task unassignment route', function() { + actingAs($this->user); + delete(route('courses.manage.tracks.unassign', [$this->course, $this->track, $this->task])) + ->assertStatus(403); +}); + +it('does not allow professors to unassign a task from a track not associated with their course', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + delete(route('courses.manage.tracks.unassign', [$course, $this->track, $this->task])) + ->assertStatus(403); +}); + +it('does not allow professors to unassign a task not in their course from a track in a course that is not theirs', function() { + actingAs($this->professor); + $course = Course::factory()->create(); + $track = CourseTrack::factory()->for($course)->create(); + $task = Task::factory()->for($course)->create(); + delete(route('courses.manage.tracks.unassign', [$course, $track, $task])) + ->assertStatus(403); +}); + +it('allows a professor to unassign a task from a track within their course', function() { + actingAs($this->professor); + $this->task->track()->associate($this->track); + $this->task->save(); + + delete(route('courses.manage.tracks.unassign', [$this->course, $this->track, $this->task])) + ->assertSessionHasNoErrors() + ->assertRedirect(); +}); diff --git a/tests/Feature/Course/TrackTest.php b/tests/Feature/Course/TrackTest.php index 007af476..57d5ee96 100644 --- a/tests/Feature/Course/TrackTest.php +++ b/tests/Feature/Course/TrackTest.php @@ -17,7 +17,9 @@ $this->track2 = CourseTrack::factory(['name' => 'Hello Miguel', 'description' => 'abcd'])->for($this->course)->for($this->root, 'parent')->create(); $this->task = Task::factory()->for($this->course)->for($this->track1, 'track')->create(); $this->task2 = Task::factory()->for($this->course)->for($this->track2, 'track')->create(); + $this->user = User::factory()->hasAttached($this->course)->create(); + $this->professor = User::factory()->hasAttached($this->course, ['role' => 'teacher'])->create(); }); it('allows a student to inspect tracks', function() { @@ -44,3 +46,26 @@ })->skip(); // todo: gitlab actions should be extracted into a service so they can be mocked during testing it('does not allow students to start projects that does not follow the track path')->skip(); + +it('does not allow users to see tracks in other courses', function() { + actingAs($this->user); + $course = Course::factory()->create(); + $track = CourseTrack::factory()->for($course)->create(); + get("/courses/{$course->id}/tracks/{$track->id}") + ->assertStatus(403); +}); + +it('returns 404 when trying to access a track that does not exist', function() { + actingAs($this->user); + get("/courses/{$this->course->id}/tracks/12341") + ->assertStatus(404); +}); + +it('returns 403 when trying to access a track that does not exist from another course', function() { + actingAs($this->user); + $course = Course::factory()->create(); + get("/courses/{$course->id}/tracks/12341") + ->assertStatus(404); + + // TODO: Figure out why the CoursePolicy#view is not being run when running this test, it should return 403 +});