feat: implement Assignment and Submission management; add controllers, requests, and services for assignment functionality
This commit is contained in:
parent
024dc4dd5a
commit
573d834905
@ -178,7 +178,42 @@ ## 4. Controller, Request, dan Service
|
|||||||
```
|
```
|
||||||
- Otorisasi berbasis permission (Spatie) dicek di dua tempat:
|
- Otorisasi berbasis permission (Spatie) dicek di dua tempat:
|
||||||
- Route-level: middleware `permission:create-xxx` dipasang per-route/per-group.
|
- Route-level: middleware `permission:create-xxx` dipasang per-route/per-group.
|
||||||
- Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record.
|
- Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. Untuk route tanpa FormRequest (mis. `destroy()` yang tidak butuh validasi input), pengecekan yang sama dilakukan inline dengan `abort_if()`/`abort_unless()` di Controller.
|
||||||
|
- **Predikat kepemilikan itu sendiri ditaruh sebagai method di Model (`User`, atau model pemilik lain yang relevan), bukan didefinisikan ulang di tiap Controller/FormRequest yang butuh.** Satu aturan bisnis harus punya satu sumber kebenaran — supaya konsisten dan gampang di-test. Penamaan: `is<Peran>Of($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage<Model>($target)` untuk gate umum "boleh mengubah/menghapus record ini".
|
||||||
|
```php
|
||||||
|
// Bad — predikat yang sama diulang di FormRequest DAN Controller
|
||||||
|
// (FormRequest)
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$assignment = $this->route('assignment');
|
||||||
|
return $this->user()->can('update-assignments')
|
||||||
|
&& (! $this->user()->hasRole('dosen') || $assignment->courseClass->lecturer_id === $this->user()->lecturer?->id);
|
||||||
|
}
|
||||||
|
// (Controller::destroy(), butuh predikat yang sama karena tidak ada FormRequest)
|
||||||
|
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
|
||||||
|
{
|
||||||
|
$user = request()->user();
|
||||||
|
abort_if($user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good — predikat di Model, dipakai dari FormRequest maupun Controller
|
||||||
|
// (User model)
|
||||||
|
public function canManageAssignment(Assignment $assignment): bool
|
||||||
|
{
|
||||||
|
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||||
|
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// (FormRequest)
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update-assignments')
|
||||||
|
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||||
|
}
|
||||||
|
// (Controller::destroy())
|
||||||
|
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||||
|
```
|
||||||
- Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method:
|
- Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method:
|
||||||
```php
|
```php
|
||||||
// Bad
|
// Bad
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\AssignmentRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\AssignmentRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
use App\Services\Admin\AcademicClasses\AssignmentService;
|
use App\Services\Admin\AcademicClasses\Assignment\AssignmentService;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@ -63,6 +63,8 @@ public function update(AssignmentRequest $request, Assignment $assignment): Redi
|
|||||||
|
|
||||||
public function destroy(Assignment $assignment): RedirectResponse
|
public function destroy(Assignment $assignment): RedirectResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||||
|
|
||||||
$this->service->delete($assignment);
|
$this->service->delete($assignment);
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
use App\Models\Submission;
|
use App\Models\Submission;
|
||||||
use App\Services\Admin\AcademicClasses\SubmissionService;
|
use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -20,7 +20,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(Assignment $assignment): Response
|
public function index(Assignment $assignment): Response
|
||||||
{
|
{
|
||||||
$this->abortUnlessLecturerOwnsAssignment($assignment);
|
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||||
|
|
||||||
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
|
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
|
||||||
|
|
||||||
@ -45,17 +45,4 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment)
|
|||||||
|
|
||||||
return to_route('admin.academic-classes.assignments.index');
|
return to_route('admin.academic-classes.assignments.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A dosen may only manage submissions for classes they lecture.
|
|
||||||
*/
|
|
||||||
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
|
|
||||||
{
|
|
||||||
$user = request()->user();
|
|
||||||
|
|
||||||
abort_if(
|
|
||||||
$user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id,
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -10,7 +11,12 @@ class AssignmentRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()->can($this->isMethod('post') ? 'create-assignments' : 'update-assignments');
|
if ($this->isMethod('post')) {
|
||||||
|
return $this->user()->can('create-assignments');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()->can('update-assignments')
|
||||||
|
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
@ -19,7 +25,7 @@ public function rules(): array
|
|||||||
|
|
||||||
$courseClassRule = Rule::exists('course_classes', 'id');
|
$courseClassRule = Rule::exists('course_classes', 'id');
|
||||||
|
|
||||||
if ($user->hasRole('dosen')) {
|
if ($user->hasRole(UserRole::Dosen->value)) {
|
||||||
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class GradeSubmissionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update-assignment-submissions')
|
||||||
|
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class GradeSubmissionRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
if (! $this->user()->can('update-assignment-submissions')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->user();
|
|
||||||
$assignment = $this->route('assignment');
|
|
||||||
|
|
||||||
if ($user->hasRole('dosen') && $assignment?->courseClass?->lecturer_id !== $user->lecturer?->id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -121,6 +121,19 @@ public function canManageMaterial(Material $material): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dosen may only manage assignments (update/delete) and their submissions
|
||||||
|
* (view/grade) for classes they lecture.
|
||||||
|
*/
|
||||||
|
public function canManageAssignment(Assignment $assignment): bool
|
||||||
|
{
|
||||||
|
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||||
|
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function isAdvisorOf(Student $student): bool
|
public function isAdvisorOf(Student $student): bool
|
||||||
{
|
{
|
||||||
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -21,15 +22,15 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i
|
|||||||
])
|
])
|
||||||
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
||||||
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
|
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
||||||
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
||||||
->where('student_id', $user->student?->id);
|
->where('student_id', $user->student?->id);
|
||||||
}]))
|
}]))
|
||||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||||
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
||||||
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
|
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
||||||
$q->whereHas('registrations', function ($q) use ($user) {
|
$q->whereHas('registrations', function ($q) use ($user) {
|
||||||
$q->where('student_id', $user->student?->id)
|
$q->where('student_id', $user->student?->id)
|
||||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||||
@ -58,12 +59,13 @@ public function create(array $data, ?UploadedFile $file): Assignment
|
|||||||
|
|
||||||
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
|
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
|
||||||
{
|
{
|
||||||
$assignment->course_class_id = $data['course_class_id'];
|
$assignment->update([
|
||||||
$assignment->title = $data['title'];
|
'course_class_id' => $data['course_class_id'],
|
||||||
$assignment->description = $data['description'] ?? null;
|
'title' => $data['title'],
|
||||||
$assignment->deadline = $data['deadline'];
|
'description' => $data['description'] ?? null,
|
||||||
$assignment->status = $data['status'] ?? $assignment->status;
|
'deadline' => $data['deadline'],
|
||||||
$assignment->update();
|
'status' => $data['status'] ?? $assignment->status,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($file) {
|
if ($file) {
|
||||||
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\SubmissionStatus;
|
use App\Enums\SubmissionStatus;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
@ -94,15 +94,17 @@ export default function SubmissionIndex({ assignment, submissions }: Props) {
|
|||||||
|
|
||||||
const columns: ColumnDef<Submission>[] = [
|
const columns: ColumnDef<Submission>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'student.student_number',
|
id: 'identity',
|
||||||
header: () => <span>NIM</span>,
|
header: () => <span>NIM</span>,
|
||||||
cell: ({ row }) => row.original.student?.student_number ?? '-',
|
cell: ({ row }) => (
|
||||||
},
|
<div className="flex flex-col">
|
||||||
{
|
<span>{row.original.student?.student_number ?? '-'}</span>
|
||||||
accessorKey: 'student.user.profile.full_name',
|
<span className="text-sm text-muted-foreground">
|
||||||
header: () => <span>Nama Mahasiswa</span>,
|
{row.original.student?.user?.profile?.full_name ??
|
||||||
cell: ({ row }) =>
|
'-'}
|
||||||
row.original.student?.user?.profile?.full_name ?? '-',
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
|
|||||||
@ -42,6 +42,4 @@ export type Assignment = {
|
|||||||
graded_submissions_count: number;
|
graded_submissions_count: number;
|
||||||
/** Only present for the logged-in mahasiswa: their own submission, if any. */
|
/** Only present for the logged-in mahasiswa: their own submission, if any. */
|
||||||
submissions?: MySubmission[];
|
submissions?: MySubmission[];
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\AcademicClasses\AssignmentController;
|
use App\Http\Controllers\Admin\AcademicClasses\Assignment\AssignmentController;
|
||||||
|
use App\Http\Controllers\Admin\AcademicClasses\Assignment\SubmissionController;
|
||||||
use App\Http\Controllers\Admin\AcademicClasses\AttendanceController;
|
use App\Http\Controllers\Admin\AcademicClasses\AttendanceController;
|
||||||
use App\Http\Controllers\Admin\AcademicClasses\MaterialController;
|
use App\Http\Controllers\Admin\AcademicClasses\MaterialController;
|
||||||
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
|
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
|
||||||
use App\Http\Controllers\Admin\AcademicClasses\SubmissionController;
|
|
||||||
use App\Http\Controllers\Admin\Developer\LogController;
|
use App\Http\Controllers\Admin\Developer\LogController;
|
||||||
use App\Http\Controllers\Admin\Developer\RolePermissionController;
|
use App\Http\Controllers\Admin\Developer\RolePermissionController;
|
||||||
use App\Http\Controllers\Admin\FeedbackController;
|
use App\Http\Controllers\Admin\FeedbackController;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user