security fix and fix parent pages
API CI/CD / Validate (composer + pint) (push) Successful in 3m7s
API CI/CD / Test (PHPUnit) (push) Failing after 5m46s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
API CI/CD / Validate (composer + pint) (push) Successful in 3m7s
API CI/CD / Test (PHPUnit) (push) Failing after 5m46s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
This commit is contained in:
@@ -96,7 +96,9 @@ class ClassProgressController extends BaseApiController
|
||||
$filters = $request->validated();
|
||||
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
|
||||
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
|
||||
$filters['semester'] = $filters['semester'] ?? ($meta['semester'] ?? null);
|
||||
if ($this->isAdminProgressAliasRoute($request)) {
|
||||
$filters['group_by_week'] = true;
|
||||
}
|
||||
$paginator = $this->queryService->listReports($auth, $filters);
|
||||
|
||||
if (! empty($filters['group_by_week'])) {
|
||||
@@ -262,6 +264,12 @@ class ClassProgressController extends BaseApiController
|
||||
|| $request->is('api/v1/teacher/class-progress-view');
|
||||
}
|
||||
|
||||
private function isAdminProgressAliasRoute(ClassProgressIndexRequest $request): bool
|
||||
{
|
||||
return $request->is('api/v1/administrator/progress')
|
||||
|| $request->is('api/v1/admin/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
$schoolYear = $this->requestedSchoolYear($request, $payload);
|
||||
|
||||
$data = $this->queryService->adminIndexData(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -63,7 +63,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function card(FamilyAdminCardRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
$schoolYear = $this->requestedSchoolYear($request, $payload);
|
||||
|
||||
$family = $this->queryService->familyCard(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -79,6 +79,26 @@ class FamilyAdminController extends BaseApiController
|
||||
return $this->success(new FamilyResource($family));
|
||||
}
|
||||
|
||||
private function requestedSchoolYear($request, array $payload): string
|
||||
{
|
||||
$candidates = [
|
||||
$payload['school_year'] ?? null,
|
||||
$request->query('school_year'),
|
||||
$request->header('X-School-Year'),
|
||||
$request->header('X-Selected-School-Year'),
|
||||
Configuration::getConfig('school_year'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$value = trim((string) ($candidate ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
@@ -34,6 +34,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
@@ -286,6 +287,59 @@ class ReimbursementController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function createContext(Request $request): JsonResponse
|
||||
{
|
||||
$expenseId = (int) $request->query('expense_id', 0);
|
||||
$prefillAmount = null;
|
||||
$expense = null;
|
||||
|
||||
if ($expenseId > 0) {
|
||||
$expense = DB::table('expenses')
|
||||
->select('id', 'amount', 'description', 'purchased_by', 'receipt_path', 'retailor')
|
||||
->where('id', $expenseId)
|
||||
->first();
|
||||
if ($expense) {
|
||||
$prefillAmount = $expense->amount ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$data = $this->queryService->index([
|
||||
'school_year' => $request->query('school_year', $this->context->getSchoolYear()),
|
||||
'semester' => $request->query('semester', $this->context->getSemester()),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'expense' => $expense ? (array) $expense : null,
|
||||
'prefill_amount' => $prefillAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
public function editData(int $id): JsonResponse
|
||||
{
|
||||
$reimb = Reimbursement::query()->find($id);
|
||||
if (! $reimb) {
|
||||
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
|
||||
}
|
||||
|
||||
$data = $this->queryService->index([
|
||||
'school_year' => $reimb->school_year ?? $this->context->getSchoolYear(),
|
||||
'semester' => $reimb->semester ?? $this->context->getSemester(),
|
||||
]);
|
||||
|
||||
$payload = $reimb->toArray();
|
||||
$payload['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reimbursement' => new ReimbursementResource($payload),
|
||||
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
|
||||
'receipt_url' => $payload['receipt_url'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\Inventory\InventoryMovementStoreRequest;
|
||||
use App\Http\Requests\Inventory\InventoryMovementUpdateRequest;
|
||||
use App\Http\Resources\Inventory\InventoryMovementResource;
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -23,9 +24,65 @@ class InventoryMovementController extends BaseApiController
|
||||
{
|
||||
$rows = $this->movements->list($request->validated());
|
||||
|
||||
return $this->success([
|
||||
return $this->success(array_merge([
|
||||
'movements' => InventoryMovementResource::collection($rows),
|
||||
]);
|
||||
], $this->movementFormPayload()));
|
||||
}
|
||||
|
||||
public function editForm(int $id): JsonResponse
|
||||
{
|
||||
$movement = collect($this->movements->list(['include_voided' => 1]))
|
||||
->first(fn ($row) => (int) ($row['id'] ?? 0) === $id);
|
||||
|
||||
if (! $movement) {
|
||||
return $this->error('Movement not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success(array_merge($this->movementFormPayload(), [
|
||||
'movement' => new InventoryMovementResource($movement),
|
||||
]));
|
||||
}
|
||||
|
||||
private function movementFormPayload(): array
|
||||
{
|
||||
$items = DB::table('inventory_items')
|
||||
->select('id', 'name', 'type', 'quantity')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$teachers = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->orderBy('firstname')
|
||||
->orderBy('lastname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$students = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->orderBy('firstname')
|
||||
->orderBy('lastname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$classSections = DB::table('classSection')
|
||||
->select('class_section_id as id', 'class_section_id', 'class_section_name')
|
||||
->orderBy('class_section_name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'teachers' => $teachers,
|
||||
'students' => $students,
|
||||
'classSections' => $classSections,
|
||||
'movementTypes' => ['initial', 'in', 'out', 'adjust', 'distribution'],
|
||||
'semesters' => ['Fall', 'Spring'],
|
||||
];
|
||||
}
|
||||
|
||||
public function store(InventoryMovementStoreRequest $request): JsonResponse
|
||||
|
||||
@@ -37,11 +37,67 @@ class WhatsappController extends BaseApiController
|
||||
|
||||
public function index(WhatsappLinkIndexRequest $request): JsonResponse
|
||||
{
|
||||
$links = $this->linkService->paginate($request->validated());
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->context->schoolYear($payload['school_year'] ?? null);
|
||||
$semester = array_key_exists('semester', $payload)
|
||||
? (string) ($payload['semester'] ?? '')
|
||||
: $this->context->semester();
|
||||
|
||||
$payload['school_year'] = $schoolYear;
|
||||
$payload['semester'] = $semester;
|
||||
$payload['per_page'] = max((int) ($payload['per_page'] ?? 200), 200);
|
||||
|
||||
$links = $this->linkService->paginate($payload);
|
||||
$collection = new WhatsappGroupLinkCollection($links);
|
||||
$linkRows = $collection->toArray($request);
|
||||
|
||||
$sectionsRaw = $this->contactService->listParentContactsByClass($schoolYear, $semester);
|
||||
$sections = WhatsappClassSectionContactResource::collection($sectionsRaw)->resolve($request);
|
||||
|
||||
$linksBySection = [];
|
||||
foreach ($linkRows as $link) {
|
||||
$sid = (int) ($link['class_section_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$linksBySection[(string) $sid] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
$parents = [];
|
||||
foreach ($sections as $section) {
|
||||
foreach (($section['parents'] ?? []) as $parent) {
|
||||
$primaryId = (int) ($parent['primary_id'] ?? 0);
|
||||
if ($primaryId > 0 && ! isset($parents['primary:'.$primaryId])) {
|
||||
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['primary_name'] ?? ''));
|
||||
$parents['primary:'.$primaryId] = [
|
||||
'id' => $primaryId,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'email' => (string) ($parent['primary_email'] ?? ''),
|
||||
'source' => 'primary',
|
||||
];
|
||||
}
|
||||
|
||||
$secondId = (int) ($parent['second_id'] ?? 0);
|
||||
if ($secondId > 0 && ! isset($parents['second:'.$secondId])) {
|
||||
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['second_name'] ?? ''));
|
||||
$parents['second:'.$secondId] = [
|
||||
'id' => $secondId,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'email' => (string) ($parent['second_email'] ?? ''),
|
||||
'source' => 'parents',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'links' => $collection->toArray($request),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'links' => $linkRows,
|
||||
'linksBySection' => $linksBySection,
|
||||
'sections' => $sections,
|
||||
'parents' => array_values($parents),
|
||||
'meta' => $collection->with($request)['meta'],
|
||||
]);
|
||||
}
|
||||
@@ -93,6 +149,8 @@ class WhatsappController extends BaseApiController
|
||||
$contacts = $this->contactService->listParentContacts($schoolYear, $semester);
|
||||
|
||||
return $this->success([
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'contacts' => WhatsappParentContactResource::collection($contacts),
|
||||
]);
|
||||
}
|
||||
@@ -160,6 +218,21 @@ class WhatsappController extends BaseApiController
|
||||
], 'Membership saved.');
|
||||
}
|
||||
|
||||
private function splitDisplayName(string $name): array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
if (str_contains($name, ',')) {
|
||||
[$last, $first] = array_pad(array_map('trim', explode(',', $name, 2)), 2, '');
|
||||
return [$last, $first];
|
||||
}
|
||||
|
||||
return [$name, ''];
|
||||
}
|
||||
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
|
||||
@@ -13,17 +13,23 @@ use App\Http\Requests\Parents\ParentProfileUpdateRequest;
|
||||
use App\Http\Requests\Parents\ParentRegistrationRequest;
|
||||
use App\Http\Requests\Parents\ParentStudentUpdateRequest;
|
||||
use App\Http\Resources\Invoices\InvoiceResource;
|
||||
use App\Http\Resources\ClassProgress\ClassProgressShowResource;
|
||||
use App\Http\Resources\Parents\ParentAttendanceResource;
|
||||
use App\Http\Resources\Parents\ParentEmergencyContactResource;
|
||||
use App\Http\Resources\Parents\ParentStudentResource;
|
||||
use App\Models\ClassProgressReport;
|
||||
use App\Services\Parents\ParentAttendanceService;
|
||||
use App\Services\Parents\ParentEmergencyContactService;
|
||||
use App\Services\Parents\ParentEnrollmentService;
|
||||
use App\Services\Parents\ParentEventParticipationService;
|
||||
use App\Services\Parents\ParentProgressQueryService;
|
||||
use App\Services\Parents\ParentInvoiceService;
|
||||
use App\Services\Parents\ParentProfileService;
|
||||
use App\Services\Parents\ParentRegistrationService;
|
||||
use App\Services\SchoolYears\SchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ParentController extends BaseApiController
|
||||
{
|
||||
@@ -34,7 +40,9 @@ class ParentController extends BaseApiController
|
||||
private ParentRegistrationService $registrationService,
|
||||
private ParentEmergencyContactService $emergencyContactService,
|
||||
private ParentProfileService $profileService,
|
||||
private ParentEventParticipationService $eventService
|
||||
private ParentEventParticipationService $eventService,
|
||||
private ParentProgressQueryService $progressService,
|
||||
private SchoolYearContextService $schoolYears
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -92,6 +100,62 @@ class ParentController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function progress(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$schoolYear = $this->schoolYears->currentSchoolYear($request->query('school_year'));
|
||||
$semester = $request->query->has('semester')
|
||||
? $this->schoolYears->currentSemester($request->query('semester'))
|
||||
: null;
|
||||
$sectionIds = $this->progressService->parentSectionIds($guard);
|
||||
$reports = $this->progressService->reportsForSections($sectionIds, $schoolYear, $semester);
|
||||
$groups = array_values($this->progressService->groupReportsByWeek($reports));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'items' => $groups,
|
||||
'groups' => $groups,
|
||||
'students' => $this->progressService->parentStudents($guard),
|
||||
'meta' => $this->schoolYears->options($schoolYear, $semester),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function progressDetail(Request $request, ClassProgressReport $class_progress): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
if (Gate::denies('view', $class_progress)) {
|
||||
return response()->json(['ok' => false, 'status' => false, 'message' => 'Forbidden.'], 403);
|
||||
}
|
||||
|
||||
$weekStart = $class_progress->week_start?->format('Y-m-d') ?? '';
|
||||
$weeklyReports = $this->progressService->weeklyReportsForSectionWeek(
|
||||
(int) $class_progress->class_section_id,
|
||||
$weekStart
|
||||
);
|
||||
|
||||
$data = (new ClassProgressShowResource([
|
||||
'report' => $class_progress,
|
||||
'weekly_reports' => $weeklyReports,
|
||||
'status_options' => config('progress.status_options', []),
|
||||
]))->toArray($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'status' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Http\Resources\Reports\ReportCards\ReportCardClassSectionResource;
|
||||
use App\Http\Resources\Reports\ReportCards\ReportCardCompletenessStudentResource;
|
||||
use App\Http\Resources\Reports\ReportCards\ReportCardStudentMetaResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use App\Services\Parents\ParentReportCardService;
|
||||
use App\Services\Reports\ReportCards\ReportCardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -20,8 +22,10 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ReportCardsController extends BaseApiController
|
||||
{
|
||||
public function __construct(private ReportCardService $service)
|
||||
{
|
||||
public function __construct(
|
||||
private ReportCardService $service,
|
||||
private ParentReportCardService $parentReportCards,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -31,7 +35,8 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
$result = $this->service->meta(
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null
|
||||
$payload['semester'] ?? null,
|
||||
$this->parentScopeId($request)
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
@@ -88,6 +93,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
$validated = $validator->validated();
|
||||
$studentId = (int) ($validated['student_id'] ?? 0);
|
||||
if (! $this->parentCanAccessStudent($request, $studentId)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$schoolYear = $this->resolveSchoolYear($validated['school_year'] ?? null);
|
||||
$semester = $this->resolveSemester($validated['semester'] ?? null);
|
||||
|
||||
@@ -98,6 +107,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
public function studentReport(ReportCardPdfRequest $request, int $studentId)
|
||||
{
|
||||
if (! $this->parentCanAccessStudent($request, $studentId)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service->generateSingleReport($studentId, $request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -122,6 +135,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
public function classReport(ReportCardPdfRequest $request, int $classSectionId)
|
||||
{
|
||||
if ($this->parentScopeId($request) !== null) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service->generateClassReport($classSectionId, $request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -163,4 +180,45 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
return trim((string) (Configuration::getConfig('semester') ?? ''));
|
||||
}
|
||||
|
||||
private function parentCanAccessStudent(Request $request, int $studentId): bool
|
||||
{
|
||||
$parentId = $this->parentScopeId($request);
|
||||
if ($parentId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->parentReportCards->studentBelongsToPrimaryParent($studentId, $parentId);
|
||||
}
|
||||
|
||||
private function parentScopeId(Request $request): ?int
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
if (! $this->isParentFamilyUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parentId = $this->parentReportCards->resolvePrimaryParentUserId($user);
|
||||
|
||||
return $parentId !== null && $parentId > 0 ? $parentId : null;
|
||||
}
|
||||
|
||||
private function isParentFamilyUser(?User $user): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
if (in_array('parent', $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string) ($user->user_type ?? ''))), ['secondary', 'tertiary'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,37 @@ class StickersController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(StickerFormDataRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
|
||||
$students = $classSectionId > 0
|
||||
? $this->queryService->listStudentsByClass($classSectionId, $schoolYear)
|
||||
: $this->queryService->listStudentsForYear($schoolYear);
|
||||
|
||||
$rows = [];
|
||||
foreach ($students as $student) {
|
||||
$id = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'student_id' => $id,
|
||||
'primary_count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => $rows,
|
||||
'totals' => [
|
||||
'students' => count($rows),
|
||||
'stickers' => count($rows),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(StickerPrintRequest $request)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -131,4 +131,12 @@ class SchoolYearController extends BaseApiController
|
||||
'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function closingReport(int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->closingReport($schoolYear),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,12 @@ class SchoolCalendarController extends BaseApiController
|
||||
|
||||
public function index(SchoolCalendarIndexRequest $request): JsonResponse
|
||||
{
|
||||
return $this->respondWithEvents($request->validated());
|
||||
$filters = $request->validated();
|
||||
if (! $this->canListEventsForAudience($filters['audience'] ?? null)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->respondWithEvents($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +57,10 @@ class SchoolCalendarController extends BaseApiController
|
||||
*/
|
||||
public function teacherCalendarLegacy(SchoolCalendarIndexRequest $request): JsonResponse
|
||||
{
|
||||
if (! $this->canListEventsForAudience('teacher')) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->respondWithEvents([
|
||||
...$request->validated(),
|
||||
'audience' => 'teacher',
|
||||
@@ -187,4 +196,56 @@ class SchoolCalendarController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function canListEventsForAudience(?string $audience): bool
|
||||
{
|
||||
if ($this->hasAnyRole(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$audience = strtolower(trim((string) $audience));
|
||||
if ($audience === 'parent') {
|
||||
return $this->hasAnyRole(['parent']) || $this->hasUserType(['secondary', 'tertiary']);
|
||||
}
|
||||
|
||||
if ($audience === 'teacher') {
|
||||
return $this->hasAnyRole(['teacher']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function hasAnyRole(array $roles): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actual = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (in_array(strtolower($role), $actual, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $types
|
||||
*/
|
||||
private function hasUserType(array $types): bool
|
||||
{
|
||||
$type = strtolower(trim((string) (auth()->user()?->user_type ?? '')));
|
||||
|
||||
return in_array($type, array_map('strtolower', $types), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\Staff\StaffStoreRequest;
|
||||
use App\Http\Requests\Staff\StaffUpdateRequest;
|
||||
use App\Http\Resources\Staff\StaffCollection;
|
||||
use App\Http\Resources\Staff\StaffResource;
|
||||
use App\Models\Role;
|
||||
use App\Models\Staff;
|
||||
use App\Models\User;
|
||||
use App\Services\Staff\StaffCommandService;
|
||||
@@ -45,6 +46,24 @@ class StaffController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function formOptions(): JsonResponse
|
||||
{
|
||||
$roles = Role::query()
|
||||
->select('id', 'name')
|
||||
->orderBy('priority')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Role $role) => [
|
||||
'id' => (int) $role->id,
|
||||
'name' => (string) $role->name,
|
||||
])
|
||||
->all();
|
||||
|
||||
return $this->success([
|
||||
'roles' => $roles,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$staff = $this->queryService->find($id);
|
||||
|
||||
@@ -38,6 +38,10 @@ class ApiDocsAuth
|
||||
return $this->deny('Unauthorized access to documentation.', 401);
|
||||
}
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! $this->userIsAdmin((int) $user->id)) {
|
||||
return $this->deny('Admin privileges required.', 403);
|
||||
}
|
||||
@@ -55,6 +59,19 @@ class ApiDocsAuth
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (! $isVerified || $status !== 'active' || $isSuspended) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function deny(string $message, int $status): Response
|
||||
{
|
||||
return response()->json([
|
||||
|
||||
@@ -20,10 +20,27 @@ class ApiJwtAuth
|
||||
}
|
||||
|
||||
Auth::setUser($user);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (! $isVerified || $status !== 'active' || $isSuspended) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,6 @@ class EnsurePrintRequestsAdminAccess
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
if (app()->runningUnitTests()) {
|
||||
try {
|
||||
if (Auth::guard('sanctum')->user()) {
|
||||
return $next($request);
|
||||
}
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
|
||||
@@ -22,6 +22,10 @@ class MultiAuth
|
||||
if ($sanctumUser) {
|
||||
Auth::setUser($sanctumUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($sanctumUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -29,6 +33,10 @@ class MultiAuth
|
||||
if ($apiUser = Auth::guard('api')->user()) {
|
||||
Auth::setUser($apiUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($apiUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
@@ -41,6 +49,10 @@ class MultiAuth
|
||||
if ($jwtUser) {
|
||||
Auth::setUser($jwtUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($jwtUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
@@ -49,4 +61,17 @@ class MultiAuth
|
||||
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (! $isVerified || $status !== 'active' || $isSuspended) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ class RequirePermission
|
||||
return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (! $isVerified || $status !== 'active' || $isSuspended) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
if (! $this->permissions->hasPermission($userId, $permission)) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['status' => false, 'message' => 'Access denied.'], 403);
|
||||
|
||||
@@ -17,7 +17,12 @@ class TeacherPortalAuthenticate
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::guard('web')->check()) {
|
||||
Auth::setUser(Auth::guard('web')->user());
|
||||
$user = Auth::guard('web')->user();
|
||||
Auth::setUser($user);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
@@ -30,6 +35,10 @@ class TeacherPortalAuthenticate
|
||||
if ($sanctumUser) {
|
||||
Auth::setUser($sanctumUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($sanctumUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -40,6 +49,10 @@ class TeacherPortalAuthenticate
|
||||
if ($jwtUser) {
|
||||
Auth::setUser($jwtUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($jwtUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
@@ -49,4 +62,17 @@ class TeacherPortalAuthenticate
|
||||
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (! $isVerified || $status !== 'active' || $isSuspended) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest
|
||||
return [
|
||||
'class_section_id' => ['nullable', 'integer', 'min:1'],
|
||||
'teacher_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'week_start' => ['nullable', 'date_format:Y-m-d'],
|
||||
|
||||
@@ -16,6 +16,7 @@ class FamilyAdminCardRequest extends ApiFormRequest
|
||||
return [
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'family_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class FamilyAdminIndexRequest extends ApiFormRequest
|
||||
return [
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest
|
||||
{
|
||||
return [
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,19 @@ class ProgressGroupResource extends JsonResource
|
||||
$reports = $this['reports'] ?? [];
|
||||
|
||||
return [
|
||||
'id' => $this['id'] ?? $this['report_id'] ?? null,
|
||||
'report_id' => $this['report_id'] ?? $this['id'] ?? null,
|
||||
'view_url' => $this['view_url'] ?? null,
|
||||
'parent_view_url' => $this['parent_view_url'] ?? null,
|
||||
'week_start' => $this['week_start'] ?? null,
|
||||
'week_end' => $this['week_end'] ?? null,
|
||||
'class_section_id' => $this['class_section_id'] ?? null,
|
||||
'class_section_name' => $this['class_section_name'] ?? '',
|
||||
'reports' => $reports, // keyed by subject for compatibility
|
||||
'weekly_reports' => $this['weekly_reports'] ?? [],
|
||||
'reports_array' => $this['reports_array'] ?? [],
|
||||
'report_list' => $this['report_list'] ?? [],
|
||||
'subjects' => $this['subjects'] ?? array_keys($reports),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,20 +26,37 @@ class ClassProgressGroupCollection extends ResourceCollection
|
||||
$key = $sectionId.'|'.$weekStart;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$reportId = (int) $report->id;
|
||||
$groups[$key] = [
|
||||
'id' => $reportId,
|
||||
'report_id' => $reportId,
|
||||
'view_url' => url('/api/v1/class-progress/'.$reportId),
|
||||
'parent_view_url' => url('/api/v1/parents/progress/'.$reportId),
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => $report->week_end?->format('Y-m-d'),
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $report->classSection?->class_section_name ?? '',
|
||||
'reports' => [],
|
||||
'weekly_reports' => [],
|
||||
'reports_array' => [],
|
||||
'report_list' => [],
|
||||
'subjects' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$key]['reports'][$report->subject] = (new ClassProgressReportResource($report))->toArray(request());
|
||||
$reportArray = (new ClassProgressReportResource($report))->toArray(request());
|
||||
$groups[$key]['reports'][$report->subject] = $reportArray;
|
||||
$groups[$key]['weekly_reports'][] = $reportArray;
|
||||
$groups[$key]['reports_array'][] = $reportArray;
|
||||
$groups[$key]['report_list'][] = $reportArray;
|
||||
$groups[$key]['subjects'][] = $report->subject;
|
||||
}
|
||||
|
||||
$items = ProgressGroupResource::collection(collect(array_values($groups)));
|
||||
|
||||
return [
|
||||
'items' => ProgressGroupResource::collection(collect(array_values($groups))),
|
||||
'items' => $items,
|
||||
'groups' => $items,
|
||||
'pagination' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
|
||||
@@ -10,10 +10,59 @@ class ClassProgressShowResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$weekly = $this['weekly_reports'] ?? [];
|
||||
$report = $this['report'];
|
||||
$reportId = (int) ($report->id ?? 0);
|
||||
$weeklyCollection = collect($weekly);
|
||||
$matchedReport = $weeklyCollection->first(fn ($weeklyReport) => (int) ($weeklyReport->id ?? 0) === $reportId);
|
||||
if ($matchedReport) {
|
||||
$report = $matchedReport;
|
||||
}
|
||||
|
||||
$weekStart = $report->week_start?->format('Y-m-d') ?? '';
|
||||
$weekEnd = $report->week_end?->format('Y-m-d');
|
||||
$sectionId = (int) ($report->class_section_id ?? 0);
|
||||
$sectionName = $report->class_section_name ?? $report->classSection?->class_section_name ?? '';
|
||||
$weeklyReports = ClassProgressReportResource::collection($weeklyCollection);
|
||||
$reports = [];
|
||||
$reportsArray = [];
|
||||
|
||||
foreach ($weekly as $weeklyReport) {
|
||||
$subject = (string) ($weeklyReport->subject ?? '');
|
||||
if ($subject === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reportArray = (new ClassProgressReportResource($weeklyReport))->toArray($request);
|
||||
$reports[$subject] = $reportArray;
|
||||
$reportsArray[] = $reportArray;
|
||||
}
|
||||
|
||||
return [
|
||||
'report' => new ClassProgressReportResource($this['report']),
|
||||
'weekly_reports' => ClassProgressReportResource::collection(collect($weekly)),
|
||||
'id' => $reportId,
|
||||
'report_id' => $reportId,
|
||||
'view_url' => url('/api/v1/class-progress/'.$reportId),
|
||||
'parent_view_url' => url('/api/v1/parents/progress/'.$reportId),
|
||||
'report' => new ClassProgressReportResource($report),
|
||||
'weekly_reports' => $weeklyReports,
|
||||
'items' => $weeklyReports,
|
||||
'reports_array' => $reportsArray,
|
||||
'report_list' => $reportsArray,
|
||||
'reports' => $reports,
|
||||
'group' => [
|
||||
'id' => $reportId,
|
||||
'report_id' => $reportId,
|
||||
'view_url' => url('/api/v1/class-progress/'.$reportId),
|
||||
'parent_view_url' => url('/api/v1/parents/progress/'.$reportId),
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => $weekEnd,
|
||||
'class_section_id' => $sectionId,
|
||||
'class_section_name' => $sectionName,
|
||||
'reports' => $reports,
|
||||
'weekly_reports' => $weeklyReports,
|
||||
'reports_array' => $reportsArray,
|
||||
'report_list' => $reportsArray,
|
||||
'subjects' => array_keys($reports),
|
||||
],
|
||||
'status_options' => $this['status_options'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Application;
|
||||
|
||||
class BaseModel extends Model
|
||||
{
|
||||
@@ -11,17 +10,6 @@ class BaseModel extends Model
|
||||
|
||||
public function getFillable(): array
|
||||
{
|
||||
if ($this->isTestingEnv()) {
|
||||
$columns = $this->tableColumns();
|
||||
if (! empty($columns)) {
|
||||
$key = $this->getKeyName();
|
||||
|
||||
return array_values(array_filter($columns, static function ($col) use ($key) {
|
||||
return $col !== $key;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
$fillable = parent::getFillable();
|
||||
if ($this->timestamps) {
|
||||
if (! in_array('created_at', $fillable, true)) {
|
||||
@@ -35,33 +23,6 @@ class BaseModel extends Model
|
||||
return $fillable;
|
||||
}
|
||||
|
||||
private function isTestingEnv(): bool
|
||||
{
|
||||
$env = getenv('APP_ENV')
|
||||
?: ($_SERVER['APP_ENV'] ?? ($_ENV['APP_ENV'] ?? null));
|
||||
|
||||
if ($env === 'testing') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (function_exists('app')) {
|
||||
try {
|
||||
$app = app();
|
||||
if ($app instanceof Application && $app->runningUnitTests()) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback to env checks when the container isn't booted.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function tableColumns(): array
|
||||
{
|
||||
if (self::$tableColumnsCache === null) {
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Policies;
|
||||
use App\Models\ClassProgressReport;
|
||||
use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ClassProgressReportPolicy
|
||||
{
|
||||
@@ -19,7 +21,9 @@ class ClassProgressReportPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->isAuthor($user, $report) || $this->teachesSection($user, $report);
|
||||
return $this->isAuthor($user, $report)
|
||||
|| $this->teachesSection($user, $report)
|
||||
|| $this->parentHasEnrolledSection($user, $report);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@@ -67,4 +71,34 @@ class ClassProgressReportPolicy
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function parentHasEnrolledSection(User $user, ClassProgressReport $report): bool
|
||||
{
|
||||
if (! Schema::hasTable('enrollments')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parentId = (int) (request()?->attributes?->get('primary_parent_id') ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
if (! $this->isParent($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parentId = (int) $user->id;
|
||||
}
|
||||
|
||||
return DB::table('enrollments')
|
||||
->where('parent_id', $parentId)
|
||||
->where('class_section_id', (int) $report->class_section_id)
|
||||
->where('is_withdrawn', 0)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function isParent(User $user): bool
|
||||
{
|
||||
return $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->contains('parent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\TeacherClass;
|
||||
use App\Models\User;
|
||||
use App\Services\SchoolYears\SchoolYearContextService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ClassProgressQueryService
|
||||
@@ -29,6 +30,20 @@ class ClassProgressQueryService
|
||||
->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc');
|
||||
|
||||
if (! $this->isAdmin($user)) {
|
||||
if ($this->isParent($user)) {
|
||||
$parentSectionIds = $this->parentSectionIds((int) $user->id);
|
||||
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
$sid = (int) $filters['class_section_id'];
|
||||
if (! in_array($sid, $parentSectionIds, true)) {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
} elseif ($parentSectionIds !== []) {
|
||||
$query->whereIn('class_progress_reports.class_section_id', $parentSectionIds);
|
||||
} else {
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
} else {
|
||||
$relaxedSectionIds = TeacherClass::distinctSectionIdsForTeacher((int) $user->id);
|
||||
|
||||
if (! empty($filters['class_section_id'])) {
|
||||
@@ -46,6 +61,7 @@ class ClassProgressQueryService
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} elseif (! empty($filters['teacher_id'])) {
|
||||
$query->where('teacher_id', (int) $filters['teacher_id']);
|
||||
}
|
||||
@@ -62,12 +78,50 @@ class ClassProgressQueryService
|
||||
$query->where('status', (string) $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['school_year']) && Schema::hasColumn('class_progress_reports', 'school_year')) {
|
||||
$query->where('school_year', (string) $filters['school_year']);
|
||||
$schoolYear = $this->resolveSchoolYearFilter($filters);
|
||||
$schoolYearRange = $this->schoolYearDateRange($schoolYear);
|
||||
if ($schoolYear !== '' && Schema::hasColumn('class_progress_reports', 'school_year')) {
|
||||
$query->where(function ($yearQuery) use ($schoolYear, $schoolYearRange) {
|
||||
$yearQuery->where('class_progress_reports.school_year', $schoolYear)
|
||||
->orWhere(function ($legacyQuery) use ($schoolYear, $schoolYearRange) {
|
||||
$legacyQuery->where(function ($blankQuery) {
|
||||
$blankQuery->whereNull('class_progress_reports.school_year')
|
||||
->orWhere('class_progress_reports.school_year', '');
|
||||
});
|
||||
|
||||
if ($schoolYearRange !== null) {
|
||||
$legacyQuery->whereBetween('class_progress_reports.week_start', [
|
||||
$schoolYearRange['start'],
|
||||
$schoolYearRange['end'],
|
||||
]);
|
||||
} else {
|
||||
$legacyQuery->whereExists(function ($sectionQuery) use ($schoolYear) {
|
||||
$sectionQuery->select(DB::raw(1))
|
||||
->from('classSection as progress_filter_cs')
|
||||
->whereColumn('progress_filter_cs.class_section_id', 'class_progress_reports.class_section_id')
|
||||
->where('progress_filter_cs.school_year', $schoolYear);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['semester']) && Schema::hasColumn('class_progress_reports', 'semester')) {
|
||||
$query->where('semester', (string) $filters['semester']);
|
||||
$semester = (string) $filters['semester'];
|
||||
$query->where(function ($semesterQuery) use ($semester) {
|
||||
$semesterQuery->where('class_progress_reports.semester', $semester)
|
||||
->orWhere(function ($legacyQuery) use ($semester) {
|
||||
$legacyQuery->where(function ($blankQuery) {
|
||||
$blankQuery->whereNull('class_progress_reports.semester')
|
||||
->orWhere('class_progress_reports.semester', '');
|
||||
})->whereExists(function ($sectionQuery) use ($semester) {
|
||||
$sectionQuery->select(DB::raw(1))
|
||||
->from('classSection as progress_filter_cs')
|
||||
->whereColumn('progress_filter_cs.class_section_id', 'class_progress_reports.class_section_id')
|
||||
->where('progress_filter_cs.semester', $semester);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['week_start'])) {
|
||||
@@ -157,6 +211,60 @@ class ClassProgressQueryService
|
||||
return $this->schoolYears->options($schoolYear, $semester);
|
||||
}
|
||||
|
||||
private function resolveSchoolYearFilter(array $filters): string
|
||||
{
|
||||
$schoolYear = trim((string) ($filters['school_year'] ?? ''));
|
||||
if ($schoolYear !== '') {
|
||||
return $schoolYear;
|
||||
}
|
||||
|
||||
$schoolYearId = (int) ($filters['school_year_id'] ?? 0);
|
||||
if ($schoolYearId <= 0 || ! Schema::hasTable('school_years')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return trim((string) DB::table('school_years')->where('id', $schoolYearId)->value('name'));
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start:string,end:string}|null
|
||||
*/
|
||||
private function schoolYearDateRange(string $schoolYear): ?array
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if ($schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('school_years')) {
|
||||
try {
|
||||
$row = DB::table('school_years')
|
||||
->where('name', $schoolYear)
|
||||
->first(['start_date', 'end_date']);
|
||||
if ($row && $row->start_date && $row->end_date) {
|
||||
return [
|
||||
'start' => substr((string) $row->start_date, 0, 10),
|
||||
'end' => substr((string) $row->end_date, 0, 10),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches) === 1) {
|
||||
return [
|
||||
'start' => $matches[1].'-08-01',
|
||||
'end' => $matches[2].'-07-31',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function statusLabel(?string $status): string
|
||||
{
|
||||
$options = (array) config('progress.status_options', []);
|
||||
@@ -180,6 +288,35 @@ class ClassProgressQueryService
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isParent(User $user): bool
|
||||
{
|
||||
return $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->contains('parent');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parentSectionIds(int $parentId): array
|
||||
{
|
||||
if ($parentId <= 0 || ! Schema::hasTable('enrollments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('enrollments')
|
||||
->where('parent_id', $parentId)
|
||||
->where('is_withdrawn', 0)
|
||||
->whereNotNull('class_section_id')
|
||||
->distinct()
|
||||
->pluck('class_section_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter(fn ($id) => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* legacy `ClassProgressController::resolveAssignedTeacherIds`.
|
||||
*
|
||||
|
||||
@@ -18,6 +18,7 @@ class ParentAttendanceService
|
||||
->join('students', 'students.id', '=', 'attendance_data.student_id')
|
||||
->where('students.parent_id', $parentId)
|
||||
->where('attendance_data.school_year', $selectedYear)
|
||||
->whereRaw("LOWER(TRIM(attendance_data.status)) IN ('absent', 'late')")
|
||||
->orderBy('attendance_data.date', 'desc')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Services\ClassProgress\ClassProgressQueryService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* legacy {@see ParentProgressController} read-side logic.
|
||||
@@ -101,24 +102,104 @@ class ParentProgressQueryService
|
||||
*
|
||||
* @param list<int> $sectionIds
|
||||
*/
|
||||
public function reportsForSections(array $sectionIds): Collection
|
||||
public function reportsForSections(array $sectionIds, ?string $schoolYear = null, ?string $semester = null): Collection
|
||||
{
|
||||
$sectionIds = array_values(array_filter(array_map('intval', $sectionIds)));
|
||||
if ($sectionIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ClassProgressReport::query()
|
||||
$attachmentMap = [];
|
||||
$query = ClassProgressReport::query()
|
||||
->select('class_progress_reports.*')
|
||||
->addSelect([
|
||||
'cs.class_section_name',
|
||||
DB::raw('CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name'),
|
||||
'u.firstname as teacher_firstname',
|
||||
'u.lastname as teacher_lastname',
|
||||
])
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'class_progress_reports.class_section_id')
|
||||
->leftJoin('users as u', 'u.id', '=', 'class_progress_reports.teacher_id')
|
||||
->whereIn('class_progress_reports.class_section_id', $sectionIds)
|
||||
->orderByDesc('class_progress_reports.week_start')
|
||||
->get();
|
||||
->whereIn('class_progress_reports.class_section_id', $sectionIds);
|
||||
|
||||
$schoolYear = trim((string) $schoolYear);
|
||||
if ($schoolYear !== '' && Schema::hasColumn('class_progress_reports', 'school_year')) {
|
||||
$schoolYearRange = $this->schoolYearDateRange($schoolYear);
|
||||
$query->where(function ($yearQuery) use ($schoolYear, $schoolYearRange) {
|
||||
$yearQuery->where('class_progress_reports.school_year', $schoolYear)
|
||||
->orWhere(function ($legacyQuery) use ($schoolYear, $schoolYearRange) {
|
||||
$legacyQuery->where(function ($blankQuery) {
|
||||
$blankQuery->whereNull('class_progress_reports.school_year')
|
||||
->orWhere('class_progress_reports.school_year', '');
|
||||
});
|
||||
|
||||
if ($schoolYearRange !== null) {
|
||||
$legacyQuery->whereBetween('class_progress_reports.week_start', [
|
||||
$schoolYearRange['start'],
|
||||
$schoolYearRange['end'],
|
||||
]);
|
||||
} else {
|
||||
$legacyQuery->where('cs.school_year', $schoolYear);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$semester = trim((string) $semester);
|
||||
if ($semester !== '' && Schema::hasColumn('class_progress_reports', 'semester')) {
|
||||
$query->where(function ($semesterQuery) use ($semester) {
|
||||
$semesterQuery->where('class_progress_reports.semester', $semester)
|
||||
->orWhere(function ($legacyQuery) use ($semester) {
|
||||
$legacyQuery->where(function ($blankQuery) {
|
||||
$blankQuery->whereNull('class_progress_reports.semester')
|
||||
->orWhere('class_progress_reports.semester', '');
|
||||
})->where('cs.semester', $semester);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('class_progress_reports.week_start')->get();
|
||||
$attachmentMap = $this->classProgressQuery->attachmentMap($rows->pluck('id')->all());
|
||||
|
||||
return $rows
|
||||
->each(function (ClassProgressReport $row) use ($attachmentMap) {
|
||||
$row->setAttribute('teacher_name', trim((string) ($row->teacher_firstname ?? '').' '.(string) ($row->teacher_lastname ?? '')));
|
||||
$row->setAttribute('attachments', $attachmentMap[$row->id] ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{start:string,end:string}|null
|
||||
*/
|
||||
private function schoolYearDateRange(string $schoolYear): ?array
|
||||
{
|
||||
$schoolYear = trim($schoolYear);
|
||||
if ($schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('school_years')) {
|
||||
try {
|
||||
$row = DB::table('school_years')
|
||||
->where('name', $schoolYear)
|
||||
->first(['start_date', 'end_date']);
|
||||
if ($row && $row->start_date && $row->end_date) {
|
||||
return [
|
||||
'start' => substr((string) $row->start_date, 0, 10),
|
||||
'end' => substr((string) $row->end_date, 0, 10),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches) === 1) {
|
||||
return [
|
||||
'start' => $matches[1].'-08-01',
|
||||
'end' => $matches[2].'-07-31',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +227,12 @@ class ParentProgressQueryService
|
||||
|
||||
$key = $weekStart.':'.$sectionId;
|
||||
if (! isset($reportGroups[$key])) {
|
||||
$reportId = (int) $row->id;
|
||||
$reportGroups[$key] = [
|
||||
'id' => $reportId,
|
||||
'report_id' => $reportId,
|
||||
'view_url' => url('/api/v1/class-progress/'.$reportId),
|
||||
'parent_view_url' => url('/api/v1/parents/progress/'.$reportId),
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => $row->week_end instanceof CarbonInterface
|
||||
? $row->week_end->format('Y-m-d')
|
||||
@@ -154,6 +240,10 @@ class ParentProgressQueryService
|
||||
'class_section_name' => $row->class_section_name ?? '',
|
||||
'class_section_id' => $sectionId,
|
||||
'reports' => [],
|
||||
'weekly_reports' => [],
|
||||
'reports_array' => [],
|
||||
'report_list' => [],
|
||||
'subjects' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -162,7 +252,12 @@ class ParentProgressQueryService
|
||||
continue;
|
||||
}
|
||||
|
||||
$reportGroups[$key]['reports'][$subject] = (new ClassProgressReportResource($row))->toArray(request());
|
||||
$report = (new ClassProgressReportResource($row))->toArray(request());
|
||||
$reportGroups[$key]['reports'][$subject] = $report;
|
||||
$reportGroups[$key]['weekly_reports'][] = $report;
|
||||
$reportGroups[$key]['reports_array'][] = $report;
|
||||
$reportGroups[$key]['report_list'][] = $report;
|
||||
$reportGroups[$key]['subjects'][] = $subject;
|
||||
}
|
||||
|
||||
return $reportGroups;
|
||||
@@ -175,13 +270,21 @@ class ParentProgressQueryService
|
||||
*/
|
||||
public function weeklyReportsForSectionWeek(int $classSectionId, string $weekStartYmd): array
|
||||
{
|
||||
return ClassProgressReport::query()
|
||||
$rows = ClassProgressReport::query()
|
||||
->with(['classSection', 'teacher'])
|
||||
->where('class_section_id', $classSectionId)
|
||||
->whereDate('week_start', $weekStartYmd)
|
||||
->orderBy('subject')
|
||||
->get()
|
||||
->all();
|
||||
|
||||
$attachments = $this->attachmentMapForReportIds(array_map(fn (ClassProgressReport $row) => (int) $row->id, $rows));
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row->setAttribute('attachments', $attachments[$row->id] ?? []);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,10 +27,11 @@ class ReportCardService
|
||||
$this->semester = trim((string) (Configuration::getConfig('semester') ?? ''));
|
||||
}
|
||||
|
||||
public function meta(?string $schoolYear, ?string $semester): array
|
||||
public function meta(?string $schoolYear, ?string $semester, ?int $parentId = null): array
|
||||
{
|
||||
$year = trim((string) ($schoolYear ?? $this->schoolYear));
|
||||
$sem = trim((string) ($semester ?? $this->semester));
|
||||
$parentId = (int) ($parentId ?? 0);
|
||||
|
||||
if ($year === '') {
|
||||
$yr = DB::table('classSection')->select('school_year')->orderBy('school_year', 'DESC')->first();
|
||||
@@ -117,6 +118,9 @@ class ReportCardService
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $year);
|
||||
if ($parentId > 0) {
|
||||
$builder->where('s.parent_id', $parentId);
|
||||
}
|
||||
if ($sem !== '') {
|
||||
$this->applySemesterFilter($builder, $sem, 'ss.semester');
|
||||
}
|
||||
@@ -141,6 +145,9 @@ class ReportCardService
|
||||
if ($year !== '') {
|
||||
$fallback->where('sc.school_year', $year);
|
||||
}
|
||||
if ($parentId > 0) {
|
||||
$fallback->where('students.parent_id', $parentId);
|
||||
}
|
||||
$students = $fallback
|
||||
->where('students.is_active', 1)
|
||||
->groupBy('students.id', 'students.firstname', 'students.lastname')
|
||||
@@ -160,6 +167,9 @@ class ReportCardService
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $year);
|
||||
if ($parentId > 0) {
|
||||
$builder->where('s.parent_id', $parentId);
|
||||
}
|
||||
$classSections = $builder
|
||||
->groupBy('cs.id', 'cs.class_section_id', 'cs.class_section_name', 'cs.school_year')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
@@ -174,6 +184,7 @@ class ReportCardService
|
||||
->join('students as s', 's.id', '=', 'sc.student_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $year)
|
||||
->when($parentId > 0, fn ($query) => $query->where('s.parent_id', $parentId))
|
||||
->groupBy('cs.id', 'cs.class_section_id', 'cs.class_section_name', 'cs.school_year')
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()
|
||||
@@ -571,6 +582,10 @@ class ReportCardService
|
||||
$data['report_date'] = $reportDate['iso'];
|
||||
$data['report_date_display'] = $reportDate['display'];
|
||||
|
||||
if (! $this->ensureFpdfAvailable()) {
|
||||
return ['ok' => false, 'message' => 'Report card PDF renderer is unavailable.'];
|
||||
}
|
||||
|
||||
$pdf = new \FPDF('P', 'mm', 'Letter');
|
||||
$this->formatReportPDF($pdf, $data);
|
||||
|
||||
@@ -613,6 +628,10 @@ class ReportCardService
|
||||
return ['ok' => false, 'message' => 'No students found for this class section.'];
|
||||
}
|
||||
|
||||
if (! $this->ensureFpdfAvailable()) {
|
||||
return ['ok' => false, 'message' => 'Report card PDF renderer is unavailable.'];
|
||||
}
|
||||
|
||||
$pdf = new \FPDF('P', 'mm', 'Letter');
|
||||
|
||||
foreach ($scores as $score) {
|
||||
@@ -749,6 +768,22 @@ class ReportCardService
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureFpdfAvailable(): bool
|
||||
{
|
||||
if (class_exists('FPDF', false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$path = base_path('app/ThirdParty/fpdf/fpdf.php');
|
||||
if (! is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
require_once $path;
|
||||
|
||||
return class_exists('FPDF', false);
|
||||
}
|
||||
|
||||
private function resolveAnchorSunday(): string
|
||||
{
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? config('app.timezone'));
|
||||
|
||||
@@ -151,6 +151,61 @@ class SchoolYearClosureService
|
||||
];
|
||||
}
|
||||
|
||||
public function closingReport(int $id): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
$audit = $this->latestClosureAudit((int) $year->id);
|
||||
$metadata = is_array($audit?->metadata) ? $audit->metadata : [];
|
||||
|
||||
$newYearName = trim((string) ($metadata['new_school_year'] ?? ''));
|
||||
if ($newYearName === '') {
|
||||
$newYearName = $this->resolveClosedTargetYearName($year->name);
|
||||
}
|
||||
|
||||
$newYear = $newYearName !== ''
|
||||
? SchoolYear::query()->where('name', $newYearName)->first()
|
||||
: null;
|
||||
|
||||
$promotionCounts = is_array($metadata['promotion_counts'] ?? null)
|
||||
? $metadata['promotion_counts']
|
||||
: [];
|
||||
$balanceCounts = is_array($metadata['balance_counts'] ?? null)
|
||||
? $metadata['balance_counts']
|
||||
: [];
|
||||
$transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null);
|
||||
|
||||
$warnings = [];
|
||||
if (! $audit) {
|
||||
$warnings[] = 'No closure audit log was found for this school year. Totals were reconstructed from current records where possible.';
|
||||
}
|
||||
if ($year->status !== SchoolYear::STATUS_CLOSED) {
|
||||
$warnings[] = sprintf('School year %s is currently %s, so this is a live preview rather than a final closure report.', $year->name, $year->status);
|
||||
}
|
||||
if ($newYearName !== '' && ! $newYear) {
|
||||
$warnings[] = sprintf('The closure target school year %s was referenced but no matching school_years row was found.', $newYearName);
|
||||
}
|
||||
|
||||
return [
|
||||
'old_school_year' => $this->presentSchoolYear($year),
|
||||
'new_school_year' => $this->presentSchoolYear($newYear),
|
||||
'closed_by' => $this->resolveUserName((int) ($year->closed_by ?? $audit?->user_id ?? 0)),
|
||||
'closed_at' => optional($year->closed_at ?? $audit?->created_at)->toDateTimeString(),
|
||||
'audit_reference' => $audit ? sprintf('audit_logs:%d', (int) $audit->id) : null,
|
||||
'totals' => [
|
||||
'students_promoted' => (int) ($promotionCounts['promote'] ?? 0),
|
||||
'students_repeated' => (int) ($promotionCounts['repeat'] ?? 0),
|
||||
'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0),
|
||||
'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0),
|
||||
'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0),
|
||||
'parents_with_unpaid_balances' => (int) ($balanceCounts['transferred_parents'] ?? $transferTotals['parents_with_unpaid_balances']),
|
||||
'total_unpaid_balance_transferred' => round((float) ($balanceCounts['transferred_amount'] ?? $transferTotals['total_unpaid_balance_transferred']), 2),
|
||||
'parents_with_credit_balances' => (int) ($balanceCounts['credit_parents'] ?? $transferTotals['parents_with_credit_balances']),
|
||||
'total_credit_transferred' => round((float) ($balanceCounts['credit_amount'] ?? $transferTotals['total_credit_transferred']), 2),
|
||||
],
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
public function validateClose(int $id, array $payload): array
|
||||
{
|
||||
$year = $this->findOrFail($id);
|
||||
@@ -1016,6 +1071,103 @@ class SchoolYearClosureService
|
||||
->all();
|
||||
}
|
||||
|
||||
private function latestClosureAudit(int $schoolYearId): ?AuditLog
|
||||
{
|
||||
if (! Schema::hasTable('audit_logs')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AuditLog::query()
|
||||
->where('action', 'school_year_closed')
|
||||
->where(function ($query) use ($schoolYearId) {
|
||||
$query->where('record_id', $schoolYearId)
|
||||
->orWhere(function ($nested) use ($schoolYearId) {
|
||||
$nested->where('table_name', 'school_years')
|
||||
->where('record_id', $schoolYearId);
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveClosedTargetYearName(string $fromSchoolYear): string
|
||||
{
|
||||
if (Schema::hasTable('parent_balance_transfers')) {
|
||||
$target = DB::table('parent_balance_transfers')
|
||||
->where('from_school_year', $fromSchoolYear)
|
||||
->whereNotNull('to_school_year')
|
||||
->orderByDesc('created_at')
|
||||
->value('to_school_year');
|
||||
|
||||
if (is_string($target) && trim($target) !== '') {
|
||||
return trim($target);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolver->suggestNextName($fromSchoolYear) ?? '';
|
||||
}
|
||||
|
||||
private function transferTotalsForClosedYear(string $fromSchoolYear, ?string $toSchoolYear = null): array
|
||||
{
|
||||
$totals = [
|
||||
'parents_with_unpaid_balances' => 0,
|
||||
'total_unpaid_balance_transferred' => 0.0,
|
||||
'parents_with_credit_balances' => 0,
|
||||
'total_credit_transferred' => 0.0,
|
||||
];
|
||||
|
||||
if (! Schema::hasTable('parent_balance_transfers')) {
|
||||
return $totals;
|
||||
}
|
||||
|
||||
$rows = DB::table('parent_balance_transfers')
|
||||
->where('from_school_year', $fromSchoolYear)
|
||||
->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear))
|
||||
->get(['amount']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = round((float) ($row->amount ?? 0), 2);
|
||||
if ($amount > 0.01) {
|
||||
$totals['parents_with_unpaid_balances']++;
|
||||
$totals['total_unpaid_balance_transferred'] += $amount;
|
||||
} elseif ($amount < -0.01) {
|
||||
$totals['parents_with_credit_balances']++;
|
||||
$totals['total_credit_transferred'] += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
$totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2);
|
||||
$totals['total_credit_transferred'] = round($totals['total_credit_transferred'], 2);
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
private function resolveUserName(int $userId): string|int|null
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('users')) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$user = DB::table('users')->where('id', $userId)->first();
|
||||
if (! $user) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$name = trim((string) ($user->firstname ?? '').' '.(string) ($user->lastname ?? ''));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$email = trim((string) ($user->email ?? ''));
|
||||
|
||||
return $email !== '' ? $email : $userId;
|
||||
}
|
||||
|
||||
private function presentSchoolYear(?SchoolYear $year): ?array
|
||||
{
|
||||
if (! $year) {
|
||||
|
||||
@@ -36,7 +36,7 @@ class SchoolCalendarMutationService
|
||||
|
||||
public function update(CalendarEvent $event, array $payload, array $targets = []): array
|
||||
{
|
||||
$data = $this->normalizePayload($payload);
|
||||
$data = $this->normalizePayload($payload, true);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($event, $data) {
|
||||
@@ -69,8 +69,41 @@ class SchoolCalendarMutationService
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePayload(array $payload): array
|
||||
private function normalizePayload(array $payload, bool $partial = false): array
|
||||
{
|
||||
if ($partial) {
|
||||
$data = [];
|
||||
foreach ([
|
||||
'title',
|
||||
'description',
|
||||
'event_type',
|
||||
'date',
|
||||
'notify_parent',
|
||||
'notify_teacher',
|
||||
'notify_admin',
|
||||
'no_school',
|
||||
'school_year',
|
||||
'semester',
|
||||
] as $key) {
|
||||
if (! array_key_exists($key, $payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($key, ['notify_parent', 'notify_teacher', 'notify_admin', 'no_school'], true)) {
|
||||
$data[$key] = ! empty($payload[$key]) ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[$key] = $payload[$key];
|
||||
}
|
||||
|
||||
if (! CalendarEvent::supportsEventType()) {
|
||||
unset($data['event_type']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'title' => (string) ($payload['title'] ?? ''),
|
||||
'description' => $payload['description'] ?? null,
|
||||
|
||||
@@ -12,22 +12,38 @@ class WhatsappContextService
|
||||
|
||||
public function schoolYear(?string $override = null): string
|
||||
{
|
||||
$override = trim((string) $override);
|
||||
if ($override !== '') {
|
||||
return $override;
|
||||
foreach ([
|
||||
$override,
|
||||
request()->query('school_year'),
|
||||
request()->header('X-School-Year'),
|
||||
request()->header('X-Selected-School-Year'),
|
||||
Configuration::getConfig('school_year'),
|
||||
] as $candidate) {
|
||||
$value = trim((string) ($candidate ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
return '';
|
||||
}
|
||||
|
||||
public function semester(?string $override = null): string
|
||||
{
|
||||
$override = trim((string) $override);
|
||||
if ($override !== '') {
|
||||
return $override;
|
||||
foreach ([
|
||||
$override,
|
||||
request()->query('semester'),
|
||||
request()->header('X-Semester'),
|
||||
request()->header('X-Selected-Semester'),
|
||||
Configuration::getConfig('semester'),
|
||||
] as $candidate) {
|
||||
$value = trim((string) ($candidate ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return trim((string) (Configuration::getConfig('semester') ?? ''));
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+44
-6
@@ -302,6 +302,12 @@ Route::prefix('v1')->group(function () {
|
||||
Route::get('{id}/file', [PrintRequestsController::class, 'download'])->whereNumber('id');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:api', 'print_requests.admin'])->prefix('administrator/print-requests')->group(function () {
|
||||
Route::get('/', [PrintRequestsController::class, 'admin']);
|
||||
Route::patch('{id}/status', [PrintRequestsController::class, 'adminStatus'])->whereNumber('id');
|
||||
Route::post('{id}/status', [PrintRequestsController::class, 'adminStatus'])->whereNumber('id');
|
||||
});
|
||||
|
||||
Route::middleware('auth:api')->prefix('info-icon')->group(function () {
|
||||
Route::get('profile', [InfoIconController::class, 'profileIcon']);
|
||||
});
|
||||
@@ -318,6 +324,8 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']);
|
||||
Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify'])->middleware('school_year.editable');
|
||||
Route::get('progress', [ClassProgressController::class, 'index']);
|
||||
Route::get('progress/meta', [ClassProgressController::class, 'meta']);
|
||||
|
||||
Route::prefix('trophy')->group(function () {
|
||||
Route::get('/', [AdministratorTrophyController::class, 'index']);
|
||||
@@ -375,6 +383,7 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::prefix('staff')->group(function () {
|
||||
Route::get('/', [StaffController::class, 'index']);
|
||||
Route::get('form-options', [StaffController::class, 'formOptions']);
|
||||
Route::get('{id}', [StaffController::class, 'show']);
|
||||
Route::post('/', [StaffController::class, 'store']);
|
||||
Route::patch('{id}', [StaffController::class, 'update']);
|
||||
@@ -425,6 +434,8 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::prefix('reimbursements')->group(function () {
|
||||
Route::get('under-processing', [ReimbursementController::class, 'underProcessing']);
|
||||
Route::get('create-context', [ReimbursementController::class, 'createContext']);
|
||||
Route::get('{id}/edit-data', [ReimbursementController::class, 'editData'])->whereNumber('id');
|
||||
Route::post('mark-donation', [ReimbursementController::class, 'markDonation']);
|
||||
Route::post('batches', [ReimbursementController::class, 'createBatch']);
|
||||
Route::post('batches/assign', [ReimbursementController::class, 'updateBatchAssignment']);
|
||||
@@ -434,17 +445,29 @@ Route::prefix('v1')->group(function () {
|
||||
Route::post('batches/email', [ReimbursementController::class, 'sendBatchEmail']);
|
||||
Route::get('export', [ReimbursementController::class, 'export']);
|
||||
Route::get('batches/export', [ReimbursementController::class, 'exportBatch']);
|
||||
Route::post('batch/create', [ReimbursementController::class, 'createBatch']);
|
||||
Route::post('batch/update', [ReimbursementController::class, 'updateBatchAssignment']);
|
||||
Route::post('batch/lock', [ReimbursementController::class, 'lockBatch']);
|
||||
Route::post('batch/admin-file/upload', [ReimbursementController::class, 'uploadBatchAdminFile']);
|
||||
Route::post('batch/send', [ReimbursementController::class, 'sendBatchEmail']);
|
||||
Route::get('batch/export', [ReimbursementController::class, 'exportBatch']);
|
||||
Route::get('reimbursed-expenses', [ReimbursementController::class, 'reimbursedExpenses']);
|
||||
Route::get('/', [ReimbursementController::class, 'index']);
|
||||
Route::post('/', [ReimbursementController::class, 'store']);
|
||||
Route::post('process', [ReimbursementController::class, 'process']);
|
||||
Route::put('{id}', [ReimbursementController::class, 'update']);
|
||||
Route::post('{id}', [ReimbursementController::class, 'update'])->whereNumber('id');
|
||||
Route::put('{id}', [ReimbursementController::class, 'update'])->whereNumber('id');
|
||||
});
|
||||
|
||||
Route::prefix('printables')->group(function () {
|
||||
Route::prefix('badges')->group(function () {
|
||||
Route::get('form-options', [BadgeController::class, 'formData']);
|
||||
});
|
||||
Route::prefix('stickers')->group(function () {
|
||||
Route::get('form-options', [StickersController::class, 'formData']);
|
||||
Route::get('preview', [StickersController::class, 'preview']);
|
||||
Route::post('generate', [StickersController::class, 'print']);
|
||||
});
|
||||
Route::prefix('report-card')->group(function () {
|
||||
Route::get('meta', [ReportCardsController::class, 'meta']);
|
||||
});
|
||||
@@ -480,13 +503,19 @@ Route::prefix('v1')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(['multi.auth', 'admin.access'])->prefix('admin')->group(function () {
|
||||
Route::get('progress', [ClassProgressController::class, 'index']);
|
||||
Route::get('progress/meta', [ClassProgressController::class, 'meta']);
|
||||
});
|
||||
|
||||
Route::middleware(['multi.auth', 'account.active'])->prefix('school-years')->group(function () {
|
||||
Route::get('/', [SchoolYearController::class, 'index'])->middleware('perm:school_year.view');
|
||||
Route::get('current', [SchoolYearController::class, 'current'])->middleware('perm:school_year.select');
|
||||
Route::get('options', [SchoolYearController::class, 'options'])->middleware('perm:school_year.select');
|
||||
Route::get('/', [SchoolYearController::class, 'index']);
|
||||
Route::get('current', [SchoolYearController::class, 'current']);
|
||||
Route::get('options', [SchoolYearController::class, 'options']);
|
||||
Route::post('/', [SchoolYearController::class, 'store'])->middleware('perm:school_year.create');
|
||||
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->middleware('perm:school_year.manage')->whereNumber('schoolYear');
|
||||
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
|
||||
Route::get('{schoolYear}/reports/closing', [SchoolYearController::class, 'closingReport'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
|
||||
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
|
||||
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
|
||||
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
|
||||
@@ -538,7 +567,7 @@ Route::prefix('v1')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware(app()->runningUnitTests() ? ['throttle:120,1', 'school_year.editable'] : ['multi.auth', 'school_year.editable'])->prefix('attendance-tracking')->group(function () {
|
||||
Route::middleware(['multi.auth', 'school_year.editable'])->prefix('attendance-tracking')->group(function () {
|
||||
Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']);
|
||||
Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']);
|
||||
Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']);
|
||||
@@ -655,6 +684,7 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::prefix('staff')->group(function () {
|
||||
Route::get('/', [StaffController::class, 'index']);
|
||||
Route::get('form-options', [StaffController::class, 'formOptions']);
|
||||
Route::get('{id}', [StaffController::class, 'show']);
|
||||
Route::post('/', [StaffController::class, 'store']);
|
||||
Route::patch('{id}', [StaffController::class, 'update']);
|
||||
@@ -733,9 +763,11 @@ Route::prefix('v1')->group(function () {
|
||||
Route::get('/', [RoleSwitcherController::class, 'index']);
|
||||
Route::post('switch', [RoleSwitcherController::class, 'switch']);
|
||||
});
|
||||
Route::prefix('settings/school-calendar')->group(function () {
|
||||
Route::get('events', [SchoolCalendarController::class, 'index']);
|
||||
});
|
||||
Route::middleware('admin.access')->prefix('settings/school-calendar')->group(function () {
|
||||
Route::get('options', [SchoolCalendarController::class, 'options']);
|
||||
Route::get('events', [SchoolCalendarController::class, 'index']);
|
||||
Route::get('events/{eventId}', [SchoolCalendarController::class, 'show']);
|
||||
Route::post('events', [SchoolCalendarController::class, 'store']);
|
||||
Route::patch('events/{eventId}', [SchoolCalendarController::class, 'update']);
|
||||
@@ -817,6 +849,8 @@ Route::prefix('v1')->group(function () {
|
||||
Route::get('invoices', [ParentApiController::class, 'invoices']);
|
||||
Route::get('enrollments', [ParentApiController::class, 'enrollments']);
|
||||
Route::post('enrollments', [ParentApiController::class, 'updateEnrollments']);
|
||||
Route::get('progress', [ParentApiController::class, 'progress']);
|
||||
Route::get('progress/{class_progress}', [ParentApiController::class, 'progressDetail'])->whereNumber('class_progress');
|
||||
|
||||
Route::prefix('promotions')->group(function () {
|
||||
Route::get('/', [ParentPromotionController::class, 'overview']);
|
||||
@@ -901,12 +935,15 @@ Route::prefix('v1')->group(function () {
|
||||
|
||||
Route::get('categories', [InventoryCategoryController::class, 'index']);
|
||||
Route::post('categories', [InventoryCategoryController::class, 'store']);
|
||||
Route::post('category', [InventoryCategoryController::class, 'store']);
|
||||
Route::patch('categories/{id}', [InventoryCategoryController::class, 'update']);
|
||||
Route::delete('categories/{id}', [InventoryCategoryController::class, 'destroy']);
|
||||
|
||||
Route::get('movements', [InventoryMovementController::class, 'index']);
|
||||
Route::get('movements/{id}/edit', [InventoryMovementController::class, 'editForm'])->whereNumber('id');
|
||||
Route::post('movements', [InventoryMovementController::class, 'store']);
|
||||
Route::patch('movements/{id}', [InventoryMovementController::class, 'update']);
|
||||
Route::put('movements/{id}', [InventoryMovementController::class, 'update']);
|
||||
Route::delete('movements/{id}', [InventoryMovementController::class, 'destroy']);
|
||||
Route::post('movements/bulk-delete', [InventoryMovementController::class, 'bulkDelete']);
|
||||
|
||||
@@ -1182,6 +1219,7 @@ Route::prefix('v1')->group(function () {
|
||||
Route::prefix('reports/stickers')->group(function () {
|
||||
Route::get('form-data', [StickersController::class, 'formData']);
|
||||
Route::get('students', [StickersController::class, 'studentsByClass']);
|
||||
Route::get('preview', [StickersController::class, 'preview']);
|
||||
Route::post('print', [StickersController::class, 'print']);
|
||||
});
|
||||
Route::prefix('reports/report-cards')->group(function () {
|
||||
|
||||
@@ -45,6 +45,138 @@ class ClassProgressControllerTest extends TestCase
|
||||
$this->assertCount(2, $response->json('data.items'));
|
||||
}
|
||||
|
||||
public function test_admin_grouped_index_accepts_school_year_id_and_includes_legacy_rows(): void
|
||||
{
|
||||
$admin = $this->createUser('admin@example.com');
|
||||
$this->assignRole($admin->id, 'administrator');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->where('class_section_id', 101)->update([
|
||||
'semester' => '',
|
||||
'school_year' => null,
|
||||
]);
|
||||
$schoolYearId = DB::table('school_years')->insertGetId([
|
||||
'name' => '2025-2026',
|
||||
'start_date' => '2025-08-01',
|
||||
'end_date' => '2026-07-31',
|
||||
'status' => 'active',
|
||||
'is_current' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $admin->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => null,
|
||||
'semester' => null,
|
||||
'week_start' => '2026-02-08',
|
||||
'week_end' => '2026-02-14',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->getJson('/api/v1/class-progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
$this->assertCount(1, $response->json('data.items'));
|
||||
|
||||
$administratorAlias = $this->getJson('/api/v1/administrator/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
$administratorAlias->assertOk();
|
||||
$this->assertCount(1, $administratorAlias->json('data.items'));
|
||||
|
||||
$adminAlias = $this->getJson('/api/v1/admin/progress?school_year_id='.$schoolYearId.'&group_by_week=1');
|
||||
$adminAlias->assertOk();
|
||||
$this->assertCount(1, $adminAlias->json('data.items'));
|
||||
}
|
||||
|
||||
public function test_parent_progress_returns_reports_for_enrolled_sections(): void
|
||||
{
|
||||
$parent = $this->createUser('parent@example.com');
|
||||
$teacher = $this->createUser('teacher-for-parent-progress@example.com');
|
||||
$this->assignRole($parent->id, 'parent');
|
||||
$this->seedClassSection();
|
||||
DB::table('classSection')->where('class_section_id', 101)->update([
|
||||
'semester' => '',
|
||||
'school_year' => null,
|
||||
]);
|
||||
DB::table('students')->insert([
|
||||
'id' => 301,
|
||||
'school_id' => '301',
|
||||
'firstname' => 'Student',
|
||||
'lastname' => 'One',
|
||||
'is_active' => 1,
|
||||
'is_new' => 0,
|
||||
'parent_id' => $parent->id,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
]);
|
||||
DB::table('enrollments')->insert([
|
||||
'student_id' => 301,
|
||||
'class_section_id' => 101,
|
||||
'parent_id' => $parent->id,
|
||||
'enrollment_status' => 'enrolled',
|
||||
'is_withdrawn' => 0,
|
||||
'admission_status' => 'accepted',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$report = ClassProgressReport::factory()->create([
|
||||
'teacher_id' => $teacher->id,
|
||||
'class_section_id' => 101,
|
||||
'school_year' => null,
|
||||
'semester' => null,
|
||||
'week_start' => '2026-02-08',
|
||||
'week_end' => '2026-02-14',
|
||||
]);
|
||||
ClassProgressAttachment::factory()->create([
|
||||
'report_id' => $report->id,
|
||||
'file_path' => 'storage/class_material/sample.pdf',
|
||||
'original_name' => 'sample.pdf',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($parent);
|
||||
|
||||
$response = $this->getJson('/api/v1/parents/progress?school_year=2025-2026');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('ok', true);
|
||||
$this->assertCount(1, $response->json('data.items'));
|
||||
$this->assertCount(1, $response->json('data.students'));
|
||||
$this->assertSame($report->id, $response->json('data.items.0.id'));
|
||||
$this->assertSame($report->id, $response->json('data.items.0.report_id'));
|
||||
$this->assertNotEmpty($response->json('data.items.0.view_url'));
|
||||
$this->assertNotEmpty($response->json('data.items.0.reports.Islamic Studies.attachments.0.download_url'));
|
||||
$this->assertSame($report->id, $response->json('data.items.0.weekly_reports.0.id'));
|
||||
$this->assertNotEmpty($response->json('data.items.0.weekly_reports.0.attachments.0.download_url'));
|
||||
|
||||
$sharedResponse = $this->getJson('/api/v1/class-progress?school_year=2025-2026&group_by_week=1');
|
||||
|
||||
$sharedResponse->assertOk();
|
||||
$sharedResponse->assertJsonPath('status', true);
|
||||
$this->assertCount(1, $sharedResponse->json('data.items'));
|
||||
$this->assertSame($report->id, $sharedResponse->json('data.items.0.id'));
|
||||
$this->assertSame($report->id, $sharedResponse->json('data.items.0.weekly_reports.0.id'));
|
||||
|
||||
$detailResponse = $this->getJson('/api/v1/class-progress/'.$report->id);
|
||||
|
||||
$detailResponse->assertOk();
|
||||
$detailResponse->assertJsonPath('status', true);
|
||||
$this->assertSame($report->id, $detailResponse->json('data.report.id'));
|
||||
$this->assertNotEmpty($detailResponse->json('data.weekly_reports.0.attachments.0.download_url'));
|
||||
$this->assertSame($report->id, $detailResponse->json('data.group.weekly_reports.0.id'));
|
||||
|
||||
$parentDetailResponse = $this->getJson('/api/v1/parents/progress/'.$report->id);
|
||||
|
||||
$parentDetailResponse->assertOk();
|
||||
$parentDetailResponse->assertJsonPath('ok', true);
|
||||
$parentDetailResponse->assertJsonPath('status', true);
|
||||
$this->assertSame($report->id, $parentDetailResponse->json('data.report.id'));
|
||||
$this->assertNotEmpty($parentDetailResponse->json('data.report.attachments.0.download_url'));
|
||||
$this->assertSame($report->id, $parentDetailResponse->json('data.group.weekly_reports.0.id'));
|
||||
}
|
||||
|
||||
public function test_store_creates_reports(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
@@ -243,4 +375,24 @@ class ClassProgressControllerTest extends TestCase
|
||||
|
||||
return User::query()->where('email', $email)->firstOrFail();
|
||||
}
|
||||
|
||||
private function assignRole(int $userId, string $roleName): void
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => $roleName,
|
||||
'slug' => $roleName,
|
||||
'dashboard_route' => $roleName === 'parent' ? 'parent/dashboard' : 'administrator/administratordashboard',
|
||||
'priority' => 100,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@ class ParentControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
DB::table('attendance_data')->insert([
|
||||
[
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '1',
|
||||
'date' => '2025-10-02',
|
||||
'status' => 'present',
|
||||
'reason' => null,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
[
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'student_id' => $studentId,
|
||||
@@ -41,6 +53,7 @@ class ParentControllerTest extends TestCase
|
||||
'reason' => 'Sick',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($parent);
|
||||
@@ -48,6 +61,7 @@ class ParentControllerTest extends TestCase
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['ok' => true]);
|
||||
$this->assertCount(1, $response->json('attendance'));
|
||||
$response->assertJsonPath('attendance.0.status', 'absent');
|
||||
}
|
||||
|
||||
@@ -91,6 +105,14 @@ class ParentControllerTest extends TestCase
|
||||
|
||||
private function seedParent(): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'parent',
|
||||
'slug' => 'parent',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$id = DB::table('users')->insertGetId([
|
||||
'firstname' => 'Parent',
|
||||
'lastname' => 'User',
|
||||
@@ -106,6 +128,15 @@ class ParentControllerTest extends TestCase
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $id,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($id);
|
||||
|
||||
@@ -30,6 +30,29 @@ class ReportCardsControllerTest extends TestCase
|
||||
$this->assertArrayHasKey('class_section_id', $response->json('data.classSections.0'));
|
||||
}
|
||||
|
||||
public function test_parent_meta_is_scoped_to_only_their_students(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
$admin = $this->seedUser();
|
||||
$parent = $this->seedParentUser(10);
|
||||
$data = $this->seedReportCardData($admin->id);
|
||||
$other = $this->seedOtherScoredStudent($admin->id);
|
||||
|
||||
Sanctum::actingAs($parent);
|
||||
|
||||
$response = $this->getJson('/api/v1/reports/report-cards/meta?school_year=2025-2026&semester=Fall');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
$this->assertSame([$data['student_id']], collect($response->json('data.students'))->pluck('id')->all());
|
||||
|
||||
$this->getJson('/api/v1/reports/report-cards/students/'.$other['student_id'].'?school_year=2025-2026&semester=Fall')
|
||||
->assertForbidden();
|
||||
|
||||
$this->getJson('/api/v1/reports/report-cards/classes/'.$data['class_section_id'].'?school_year=2025-2026&semester=Fall')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_completeness_returns_summary_and_students(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
@@ -155,6 +178,48 @@ class ReportCardsControllerTest extends TestCase
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($userId);
|
||||
}
|
||||
|
||||
private function seedParentUser(int $userId): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'parent',
|
||||
'slug' => 'parent',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => $userId,
|
||||
'firstname' => 'Parent',
|
||||
'lastname' => 'User',
|
||||
'cellphone' => '8888888888',
|
||||
'email' => 'parent-reportcards@example.com',
|
||||
'address_street' => '123 Parent',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'password' => bcrypt('password'),
|
||||
'user_type' => 'primary',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($userId);
|
||||
@@ -280,6 +345,59 @@ class ReportCardsControllerTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
private function seedOtherScoredStudent(int $userId): array
|
||||
{
|
||||
DB::table('students')->insert([
|
||||
'id' => 102,
|
||||
'school_id' => 'SCH3',
|
||||
'firstname' => 'Other',
|
||||
'lastname' => 'Student',
|
||||
'age' => 10,
|
||||
'gender' => 'Female',
|
||||
'photo_consent' => 1,
|
||||
'parent_id' => 11,
|
||||
'year_of_registration' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'is_active' => 1,
|
||||
]);
|
||||
|
||||
DB::table('student_class')->insert([
|
||||
'student_id' => 102,
|
||||
'class_section_id' => 200,
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'is_event_only' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('semester_scores')->insert([
|
||||
'student_id' => 102,
|
||||
'school_id' => 'SCH3',
|
||||
'class_section_id' => 200,
|
||||
'updated_by' => $userId,
|
||||
'homework_avg' => 87,
|
||||
'quiz_avg' => 86,
|
||||
'project_avg' => 85,
|
||||
'test_avg' => 84,
|
||||
'midterm_exam_score' => 83,
|
||||
'final_exam_score' => 82,
|
||||
'attendance_score' => 81,
|
||||
'ptap_score' => 80,
|
||||
'semester_score' => 84,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'student_id' => 102,
|
||||
'class_section_id' => 200,
|
||||
];
|
||||
}
|
||||
|
||||
private function seedAcknowledgement(int $studentId): void
|
||||
{
|
||||
DB::table('report_card_acknowledgements')->insert([
|
||||
|
||||
@@ -135,8 +135,15 @@ class SchoolYearControllerTest extends TestCase
|
||||
|
||||
$missing = [];
|
||||
foreach ($routes as $route) {
|
||||
$uri = $route->uri();
|
||||
$middleware = $route->gatherMiddleware();
|
||||
$hasPermission = collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:'));
|
||||
$isContextRoute = in_array($uri, [
|
||||
'api/v1/school-years',
|
||||
'api/v1/school-years/current',
|
||||
'api/v1/school-years/options',
|
||||
], true);
|
||||
$hasPermission = $isContextRoute
|
||||
|| collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:'));
|
||||
$hasAuth = collect($middleware)->contains(fn (string $middleware): bool => str_contains($middleware, 'multi.auth') || str_contains($middleware, 'auth'));
|
||||
$hasAccountGuard = in_array('account.active', $middleware, true);
|
||||
|
||||
@@ -148,6 +155,25 @@ class SchoolYearControllerTest extends TestCase
|
||||
$this->assertSame([], $missing, 'School-year routes missing auth/account/permission middleware: '.implode(', ', $missing));
|
||||
}
|
||||
|
||||
public function test_active_user_can_load_school_year_context_list_without_school_year_permission(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
|
||||
$this->actingAs(User::query()->findOrFail(10), 'api');
|
||||
|
||||
$response = $this->getJson('/api/v1/school-years');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('current_school_year', '2025-2026');
|
||||
|
||||
$this->postJson('/api/v1/school-years', [
|
||||
'name' => '2026-2027',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2027-06-30',
|
||||
])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_create_draft_school_year_via_api(): void
|
||||
{
|
||||
$this->seedClosureData();
|
||||
@@ -426,12 +452,15 @@ class SchoolYearControllerTest extends TestCase
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
$permissionId = DB::table('permissions')->insertGetId([
|
||||
'name' => $permission,
|
||||
DB::table('permissions')->updateOrInsert(
|
||||
['name' => $permission],
|
||||
[
|
||||
'description' => 'Test permission '.$permission,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
]
|
||||
);
|
||||
$permissionId = (int) DB::table('permissions')->where('name', $permission)->value('id');
|
||||
DB::table('role_permissions')->insert([
|
||||
'role_id' => 1,
|
||||
'permission_id' => $permissionId,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace Tests\Feature\Api\V1\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarMutationService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarNotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
@@ -44,6 +46,40 @@ class SchoolCalendarControllerTest extends TestCase
|
||||
$this->assertArrayHasKey('extendedProps', $response->json('data.events.0'));
|
||||
}
|
||||
|
||||
public function test_parent_can_list_only_parent_audience_events(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
$parent = $this->seedParentUser();
|
||||
$parentEventId = $this->seedEvent();
|
||||
$teacherEventId = DB::table('calendar_events')->insertGetId([
|
||||
'title' => 'Teacher Only',
|
||||
'date' => '2026-01-03',
|
||||
'description' => 'Hidden from parents',
|
||||
'notify_parent' => 0,
|
||||
'notify_admin' => 0,
|
||||
'notify_teacher' => 1,
|
||||
'no_school' => 0,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'notification_sent' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($parent);
|
||||
|
||||
$response = $this->getJson('/api/v1/settings/school-calendar/events?audience=parent&school_year=2025-2026&include_meetings=0');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', true);
|
||||
$ids = collect($response->json('data.events'))->pluck('id')->all();
|
||||
$this->assertContains($parentEventId, $ids);
|
||||
$this->assertNotContains($teacherEventId, $ids);
|
||||
|
||||
$this->getJson('/api/v1/settings/school-calendar/events?school_year=2025-2026')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_show_returns_event(): void
|
||||
{
|
||||
$this->seedConfig();
|
||||
@@ -136,7 +172,10 @@ class SchoolCalendarControllerTest extends TestCase
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->app->bind(SchoolCalendarMutationService::class, function () {
|
||||
return new class
|
||||
return new class(
|
||||
app(SchoolCalendarContextService::class),
|
||||
app(SchoolCalendarNotificationService::class),
|
||||
) extends SchoolCalendarMutationService
|
||||
{
|
||||
public function create(array $payload, array $targets = []): array
|
||||
{
|
||||
@@ -173,6 +212,14 @@ class SchoolCalendarControllerTest extends TestCase
|
||||
|
||||
private function seedUser(): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'administrator',
|
||||
'slug' => 'administrator',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$userId = DB::table('users')->insertGetId([
|
||||
'firstname' => 'Admin',
|
||||
'lastname' => 'User',
|
||||
@@ -188,6 +235,54 @@ class SchoolCalendarControllerTest extends TestCase
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($userId);
|
||||
}
|
||||
|
||||
private function seedParentUser(): User
|
||||
{
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'parent',
|
||||
'slug' => 'parent',
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$userId = DB::table('users')->insertGetId([
|
||||
'firstname' => 'Parent',
|
||||
'lastname' => 'User',
|
||||
'cellphone' => '8888888888',
|
||||
'email' => 'calendar-parent@example.com',
|
||||
'address_street' => '123 Street',
|
||||
'city' => 'City',
|
||||
'state' => 'ST',
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'password' => bcrypt('password'),
|
||||
'user_type' => 'primary',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $userId,
|
||||
'role_id' => $roleId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return User::query()->findOrFail($userId);
|
||||
|
||||
@@ -74,6 +74,8 @@ class WhatsappInviteControllerTest extends TestCase
|
||||
|
||||
public function test_send_invites_returns_error_when_no_listener(): void
|
||||
{
|
||||
Event::forget(WhatsappInvitesSend::class);
|
||||
|
||||
$user = $this->createUser();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RequirePermissionMiddlewareTest extends TestCase
|
||||
@@ -27,6 +28,8 @@ class RequirePermissionMiddlewareTest extends TestCase
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
'password' => 'hashed',
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
@@ -59,6 +62,8 @@ class RequirePermissionMiddlewareTest extends TestCase
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
'password' => 'hashed',
|
||||
'semester' => 'Fall',
|
||||
]);
|
||||
@@ -81,4 +86,77 @@ class RequirePermissionMiddlewareTest extends TestCase
|
||||
$response->assertOk();
|
||||
$response->assertSee('ok');
|
||||
}
|
||||
|
||||
public function test_multi_auth_blocks_suspended_sanctum_user(): void
|
||||
{
|
||||
Route::middleware('multi.auth')->get('/test-active-account', fn () => 'ok');
|
||||
|
||||
$user = $this->createUser([
|
||||
'email' => 'suspended@example.com',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 1,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->get('/test-active-account', ['Accept' => 'application/json'])
|
||||
->assertStatus(403)
|
||||
->assertJsonPath('message', 'Account unavailable.');
|
||||
}
|
||||
|
||||
public function test_permission_middleware_blocks_unverified_user_even_with_permission(): void
|
||||
{
|
||||
$permissionId = DB::table('permissions')->insertGetId([
|
||||
'name' => 'edit_students',
|
||||
'description' => 'Edit students',
|
||||
]);
|
||||
|
||||
$roleId = DB::table('roles')->insertGetId([
|
||||
'name' => 'admin',
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$user = $this->createUser([
|
||||
'email' => 'unverified-perm@example.com',
|
||||
'is_verified' => 0,
|
||||
'is_suspended' => 0,
|
||||
]);
|
||||
|
||||
DB::table('user_roles')->insert([
|
||||
'user_id' => $user->id,
|
||||
'role_id' => $roleId,
|
||||
]);
|
||||
|
||||
DB::table('role_permissions')->insert([
|
||||
'role_id' => $roleId,
|
||||
'permission_id' => $permissionId,
|
||||
'can_read' => 1,
|
||||
]);
|
||||
|
||||
Route::middleware('perm:edit_students')->get('/test-perm-unverified', fn () => 'ok');
|
||||
|
||||
$this->actingAs($user)->get('/test-perm-unverified', ['Accept' => 'application/json'])
|
||||
->assertStatus(403)
|
||||
->assertJsonPath('message', 'Account unavailable.');
|
||||
}
|
||||
|
||||
private function createUser(array $overrides = []): User
|
||||
{
|
||||
return User::query()->create(array_merge([
|
||||
'firstname' => 'Test',
|
||||
'lastname' => 'User',
|
||||
'email' => 'test-user@example.com',
|
||||
'cellphone' => '5555555555',
|
||||
'address_street' => '123 Main',
|
||||
'city' => 'City',
|
||||
'state' => 'CT',
|
||||
'zip' => '12345',
|
||||
'accept_school_policy' => 1,
|
||||
'status' => 'Active',
|
||||
'is_verified' => 1,
|
||||
'is_suspended' => 0,
|
||||
'password' => 'hashed',
|
||||
'semester' => 'Fall',
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class ParentAttendanceServiceTest extends TestCase
|
||||
]);
|
||||
|
||||
DB::table('attendance_data')->insert([
|
||||
[
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'student_id' => $studentId,
|
||||
@@ -61,6 +62,18 @@ class ParentAttendanceServiceTest extends TestCase
|
||||
'reason' => 'Traffic',
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
[
|
||||
'class_id' => 1,
|
||||
'class_section_id' => 1,
|
||||
'student_id' => $studentId,
|
||||
'school_id' => '1',
|
||||
'date' => '2025-10-01',
|
||||
'status' => 'present',
|
||||
'reason' => null,
|
||||
'semester' => 'Fall',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new ParentAttendanceService(new ParentConfigService);
|
||||
|
||||
Reference in New Issue
Block a user