02ba2fe6b6
API CI/CD / Validate (composer + pint) (push) Successful in 3m8s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Test (PHPUnit) (push) Failing after 6m5s
API CI/CD / Security audit (push) Failing after 52s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
470 lines
18 KiB
PHP
470 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services\ClassProgress;
|
|
|
|
use App\Models\ClassProgressAttachment;
|
|
use App\Models\ClassProgressReport;
|
|
use App\Models\Configuration;
|
|
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
|
|
{
|
|
private SchoolYearContextService $schoolYears;
|
|
|
|
public function __construct($schoolYears = null)
|
|
{
|
|
$this->schoolYears = $schoolYears instanceof SchoolYearContextService
|
|
? $schoolYears
|
|
: app(SchoolYearContextService::class);
|
|
}
|
|
|
|
public function listReports(User $user, array $filters): LengthAwarePaginator
|
|
{
|
|
$filterSectionIds = [];
|
|
if (! empty($filters['class_section_id'])) {
|
|
$filterSectionIds = $this->equivalentClassSectionIds((int) $filters['class_section_id']);
|
|
}
|
|
|
|
$query = ClassProgressReport::query()
|
|
->with(['classSection', 'teacher'])
|
|
->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::distinctAccessibleSectionIdsForTeacher((int) $user->id);
|
|
|
|
if (! empty($filters['class_section_id'])) {
|
|
$requestedSectionIds = $filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']];
|
|
// Section filter: if this user has ever taught the section, show all rows for that section
|
|
// (legacy DB often has `class_progress_reports` without matching current-year `teacher_class`).
|
|
if (array_intersect($requestedSectionIds, $relaxedSectionIds) === []) {
|
|
$query->whereRaw('1 = 0');
|
|
}
|
|
} elseif ($relaxedSectionIds !== []) {
|
|
$query->whereIn('class_section_id', $relaxedSectionIds);
|
|
} else {
|
|
$query->where('teacher_id', (int) $user->id);
|
|
}
|
|
}
|
|
} elseif (! empty($filters['teacher_id'])) {
|
|
$query->where('teacher_id', (int) $filters['teacher_id']);
|
|
}
|
|
|
|
if (! empty($filters['class_section_id'])) {
|
|
$query->whereIn(
|
|
'class_section_id',
|
|
$filterSectionIds !== [] ? $filterSectionIds : [(int) $filters['class_section_id']]
|
|
);
|
|
}
|
|
|
|
if (! empty($filters['subject'])) {
|
|
$query->where('subject', (string) $filters['subject']);
|
|
}
|
|
|
|
if (! empty($filters['status'])) {
|
|
$query->where('status', (string) $filters['status']);
|
|
}
|
|
|
|
$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')) {
|
|
$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'])) {
|
|
$query->whereDate('week_start', '>=', $filters['week_start']);
|
|
}
|
|
|
|
if (! empty($filters['week_end'])) {
|
|
$query->whereDate('week_end', '<=', $filters['week_end']);
|
|
}
|
|
|
|
$perPage = (int) ($filters['per_page'] ?? 20);
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
public function getReport(User $user, int $reportId): ClassProgressReport
|
|
{
|
|
$report = ClassProgressReport::query()->with('classSection')->findOrFail($reportId);
|
|
|
|
if ($this->isAdmin($user)) {
|
|
return $report;
|
|
}
|
|
|
|
if ($this->viewerMayAccessReport($user, $report)) {
|
|
return $report;
|
|
}
|
|
|
|
abort(404);
|
|
}
|
|
|
|
public function weeklyReports(User $user, ClassProgressReport $report): array
|
|
{
|
|
$query = ClassProgressReport::query()
|
|
->with('classSection')
|
|
->where('class_section_id', $report->class_section_id)
|
|
->whereDate('week_start', $report->week_start)
|
|
->orderBy('subject', 'asc');
|
|
|
|
// Non-admins reach this only after policy `view` passed on the anchor report (author or teaches section).
|
|
|
|
$rows = $query->get();
|
|
$attachments = $this->attachmentMap($rows->pluck('id')->all());
|
|
|
|
return $rows->map(function (ClassProgressReport $row) use ($attachments) {
|
|
$row->setAttribute('status_label', $this->statusLabel($row->status));
|
|
$row->setAttribute('attachments', $attachments[$row->id] ?? []);
|
|
|
|
return $row;
|
|
})->all();
|
|
}
|
|
|
|
public function attachmentMap(array $reportIds): array
|
|
{
|
|
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
|
|
if ($reportIds === []) {
|
|
return [];
|
|
}
|
|
|
|
$rows = ClassProgressAttachment::query()
|
|
->whereIn('report_id', $reportIds)
|
|
->orderBy('id', 'asc')
|
|
->get();
|
|
|
|
$map = [];
|
|
foreach ($rows as $row) {
|
|
$map[$row->report_id][] = [
|
|
'id' => $row->id,
|
|
'name' => $row->original_name ?: basename((string) $row->file_path),
|
|
'file_path' => $row->file_path,
|
|
];
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
public function teacherAssignments(User $user, ?string $schoolYear, ?string $semester): array
|
|
{
|
|
if ($schoolYear === null || $schoolYear === '') {
|
|
$schoolYear = (string) (config('school.school_year') ?? '');
|
|
}
|
|
|
|
$assignments = $this->teacherAssignmentsForTerm($user, $schoolYear !== '' ? $schoolYear : null);
|
|
if ($assignments !== []) {
|
|
return $assignments;
|
|
}
|
|
|
|
return $this->teacherAssignmentsForTerm($user, null);
|
|
}
|
|
|
|
public function meta(?string $schoolYear = null, ?string $semester = null): array
|
|
{
|
|
return $this->schoolYears->options($schoolYear, $semester);
|
|
}
|
|
|
|
private function resolveSchoolYearFilter(array $filters): string
|
|
{
|
|
return trim((string) ($filters['school_year'] ?? ''));
|
|
}
|
|
|
|
/**
|
|
* @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', []);
|
|
|
|
return $options[$status] ?? 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Resolve the logged-in teacher's real assignment rows. The join accepts both
|
|
* legacy section identifiers used in this codebase.
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function teacherAssignmentsForTerm(User $user, ?string $schoolYear): array
|
|
{
|
|
if (! Schema::hasTable('classSection') || ! Schema::hasTable('teacher_class')) {
|
|
return [];
|
|
}
|
|
|
|
$teacherClassHasSchoolYear = Schema::hasColumn('teacher_class', 'school_year');
|
|
$teacherClassHasSemester = Schema::hasColumn('teacher_class', 'semester');
|
|
$schoolYearSelect = $teacherClassHasSchoolYear
|
|
? 'COALESCE(NULLIF(tc.school_year, ""), cs_code.school_year, cs_pk.school_year) AS school_year'
|
|
: 'COALESCE(cs_code.school_year, cs_pk.school_year) AS school_year';
|
|
$semesterSelect = $teacherClassHasSemester
|
|
? 'COALESCE(NULLIF(tc.semester, ""), cs_code.semester, cs_pk.semester) AS semester'
|
|
: 'COALESCE(cs_code.semester, cs_pk.semester) AS semester';
|
|
|
|
$query = DB::table('teacher_class as tc')
|
|
->selectRaw("
|
|
COALESCE(cs_code.class_section_name, cs_pk.class_section_name) AS class_section_name,
|
|
COALESCE(cs_code.id, cs_pk.id) AS class_section_pk,
|
|
COALESCE(cs_code.class_section_id, cs_pk.class_section_id) AS class_section_id,
|
|
c.id AS class_id,
|
|
c.class_name,
|
|
tc.teacher_id,
|
|
tc.position,
|
|
{$schoolYearSelect},
|
|
{$semesterSelect}
|
|
")
|
|
->leftJoin('classSection as cs_code', 'tc.class_section_id', '=', 'cs_code.class_section_id')
|
|
->leftJoin('classSection as cs_pk', function ($join) {
|
|
$join->on('tc.class_section_id', '=', 'cs_pk.id')
|
|
->whereNull('cs_code.class_section_id');
|
|
})
|
|
->leftJoin('classes as c', DB::raw('COALESCE(cs_code.class_id, cs_pk.class_id)'), '=', 'c.id')
|
|
->where('tc.teacher_id', (int) $user->id)
|
|
->whereNotNull('tc.class_section_id')
|
|
->whereRaw('COALESCE(cs_code.class_section_id, cs_pk.class_section_id) IS NOT NULL');
|
|
|
|
if ($schoolYear !== null && $schoolYear !== '') {
|
|
if ($teacherClassHasSchoolYear) {
|
|
$query->where(function ($yearQuery) use ($schoolYear) {
|
|
$yearQuery->where('tc.school_year', $schoolYear)
|
|
->orWhere(function ($legacyYearQuery) use ($schoolYear) {
|
|
$legacyYearQuery->where(function ($blankYearQuery) {
|
|
$blankYearQuery->whereNull('tc.school_year')
|
|
->orWhere('tc.school_year', '');
|
|
})->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]);
|
|
});
|
|
});
|
|
} else {
|
|
$query->whereRaw('COALESCE(cs_code.school_year, cs_pk.school_year) = ?', [$schoolYear]);
|
|
}
|
|
}
|
|
|
|
return $query
|
|
->orderByRaw("CASE tc.position WHEN 'main' THEN 0 WHEN 'ta' THEN 1 ELSE 2 END")
|
|
->orderByRaw('COALESCE(cs_code.class_section_name, cs_pk.class_section_name) ASC')
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'class_section_pk' => isset($row->class_section_pk) ? (int) $row->class_section_pk : null,
|
|
'class_section_id' => (int) $row->class_section_id,
|
|
'class_section_name' => (string) $row->class_section_name,
|
|
'class_id' => isset($row->class_id) ? (int) $row->class_id : null,
|
|
'class_name' => $row->class_name ?? null,
|
|
'teacher_id' => (int) $row->teacher_id,
|
|
'teacher_role' => ucfirst((string) $row->position),
|
|
'school_year' => $row->school_year ?? null,
|
|
'semester' => $row->semester ?? null,
|
|
])
|
|
->unique('class_section_id')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
private function equivalentClassSectionIds(int $classSectionId): array
|
|
{
|
|
if ($classSectionId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$ids = [$classSectionId => true];
|
|
if (! Schema::hasTable('classSection')) {
|
|
return [$classSectionId];
|
|
}
|
|
|
|
$rows = DB::table('classSection')
|
|
->select('id', 'class_section_id')
|
|
->where('class_section_id', $classSectionId)
|
|
->orWhere('id', $classSectionId)
|
|
->get();
|
|
|
|
foreach ($rows as $row) {
|
|
foreach ([(int) $row->id, (int) $row->class_section_id] as $id) {
|
|
if ($id > 0) {
|
|
$ids[$id] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$ids = array_map('intval', array_keys($ids));
|
|
sort($ids);
|
|
|
|
return $ids;
|
|
}
|
|
|
|
private function isAdmin(User $user): bool
|
|
{
|
|
$roles = $user->roles()
|
|
->pluck('roles.name')
|
|
->map(fn ($name) => strtolower((string) $name))
|
|
->toArray();
|
|
|
|
foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) {
|
|
if (in_array($role, $roles, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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`.
|
|
*
|
|
* @return list<int>
|
|
*/
|
|
public function resolveAssignedTeacherIds(int $classSectionId): array
|
|
{
|
|
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
|
|
if ($schoolYear === '' || $classSectionId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$semester = (string) (Configuration::getConfig('semester') ?? '');
|
|
$rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear);
|
|
if ($rows === []) {
|
|
$rows = TeacherClass::assignedForSectionTerm($classSectionId, $semester, $schoolYear, null, false);
|
|
}
|
|
$ids = [];
|
|
foreach ($rows as $row) {
|
|
$id = (int) ($row['teacher_id'] ?? 0);
|
|
if ($id > 0) {
|
|
$ids[$id] = true;
|
|
}
|
|
}
|
|
|
|
return array_map('intval', array_keys($ids));
|
|
}
|
|
|
|
private function viewerMayAccessReport(User $user, ClassProgressReport $report): bool
|
|
{
|
|
if ((int) $report->teacher_id === (int) $user->id) {
|
|
return true;
|
|
}
|
|
|
|
return in_array(
|
|
(int) $report->class_section_id,
|
|
TeacherClass::distinctAccessibleSectionIdsForTeacher((int) $user->id),
|
|
true
|
|
);
|
|
}
|
|
}
|