fix api security issues and update pages issue

This commit is contained in:
root
2026-06-04 16:41:19 -04:00
parent feb6be0610
commit 5e5fe3794a
32 changed files with 1628 additions and 37 deletions
@@ -584,6 +584,308 @@ class GradingBelowSixtyService
$this->sendEmail($payload);
}
public function listAllDecisions(string $schoolYear): array
{
$saved = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$savedMap = [];
foreach ($saved as $row) {
$studentId = (int) $row->student_id;
if ($studentId > 0) {
$savedMap[$studentId] = $row;
}
}
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
's.gender',
'ss.class_section_id',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderBy('cs.class_section_name')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get();
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'school_id' => $row->school_id,
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'gender' => $row->gender,
'class_section_id' => (int) ($row->class_section_id ?? 0),
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$rows = [];
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
$yearScore = null;
}
if (isset($savedMap[$studentId])) {
$savedRow = $savedMap[$studentId];
$decision = trim((string) ($savedRow->decision ?? ''));
$source = trim((string) ($savedRow->source ?? 'pending'));
$notes = (string) ($savedRow->notes ?? '');
if (is_numeric($savedRow->year_score ?? null)) {
$yearScore = round((float) $savedRow->year_score, 2);
}
} elseif ($yearScore !== null && $yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = '';
} elseif ($yearScore !== null && isset($belowMap[$studentId])) {
$decision = trim((string) ($belowMap[$studentId]->decision ?? ''));
$source = $decision !== '' ? 'manual' : 'pending';
$notes = (string) ($belowMap[$studentId]->notes ?? '');
} else {
$decision = '';
$source = 'pending';
$notes = '';
}
$rows[] = [
'student_id' => $studentId,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'gender' => $info['gender'] ?? '',
'class_section_id' => (int) ($info['class_section_id'] ?? 0),
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$studentId]),
'is_trophy' => false,
];
}
$rowsByClass = [];
foreach ($rows as $index => $row) {
$classSectionId = (int) ($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$rowsByClass[$classSectionId][] = $index;
}
foreach ($rowsByClass as $classIndexes) {
$scores = [];
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
if (is_numeric($yearScore)) {
$scores[] = (float) $yearScore;
}
}
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
$threshold = $thresholdInfo['threshold'];
if ($threshold === null) {
continue;
}
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float) $yearScore >= $threshold;
}
}
return [
'rows' => $rows,
'generated' => !empty($savedMap),
'school_year' => $schoolYear,
];
}
public function generateAllDecisions(string $schoolYear, ?int $userId): int
{
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->get();
if ($allScoreRows->isEmpty()) {
throw new RuntimeException('No semester scores found for this school year.');
}
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$existingRows = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$existingMap = [];
foreach ($existingRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId > 0) {
$existingMap[$studentId] = $row;
}
}
$savedCount = 0;
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
continue;
}
if ($yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = null;
} elseif (isset($belowMap[$studentId]) && trim((string) ($belowMap[$studentId]->decision ?? '')) !== '') {
$decision = trim((string) $belowMap[$studentId]->decision);
$source = 'manual';
$notes = trim((string) ($belowMap[$studentId]->notes ?? ''));
$notes = $notes !== '' ? $notes : null;
} else {
$decision = null;
$source = 'pending';
$notes = null;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'class_section_name' => $info['class_section_name'] ?? null,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'generated_by' => $userId,
];
if (isset($existingMap[$studentId])) {
$existingMap[$studentId]->fill($payload)->save();
} else {
StudentDecision::query()->create($payload);
}
$savedCount++;
}
return $savedCount;
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
@@ -866,6 +1168,108 @@ class GradingBelowSixtyService
return $html;
}
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
$winners = $this->countScoresAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueHigherScores = array_values(array_unique(array_filter(
$sortedScores,
static fn ($score): bool => $score > $threshold
)));
sort($uniqueHigherScores);
foreach ($uniqueHigherScores as $candidate) {
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
if ($count === 1) {
return (float) $sortedScores[0];
}
$rank = ($percentile / 100) * ($count - 1);
$lowerIndex = (int) floor($rank);
$upperIndex = (int) ceil($rank);
$weight = $rank - $lowerIndex;
$lower = (float) $sortedScores[$lowerIndex];
$upper = (float) $sortedScores[$upperIndex];
return $lower + (($upper - $lower) * $weight);
}
private function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => is_numeric($score) && (float) $score >= $threshold
));
}
private function buildDecisionEmailDetailsHtml(array $semesters): string
{
if (empty($semesters)) {