Passed
Push — master ( d4517f...26a928 )
by Yannick
08:22
created

TrackingStatsHelper::getCourseAverageScore()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 8
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 15
rs 10
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
declare(strict_types=1);
5
6
namespace Chamilo\CoreBundle\Helpers;
7
8
use Chamilo\CoreBundle\Entity\Course;
9
use Chamilo\CoreBundle\Entity\GradebookCategory;
10
use Chamilo\CoreBundle\Entity\GradebookResult;
11
use Chamilo\CoreBundle\Entity\Session;
12
use Chamilo\CoreBundle\Entity\SessionRelCourseRelUser;
13
use Chamilo\CoreBundle\Entity\User;
14
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
15
use Chamilo\CoreBundle\Repository\SessionRepository;
16
use Chamilo\CourseBundle\Entity\CLpView;
17
use Chamilo\CourseBundle\Repository\CLpRepository;
18
use Doctrine\ORM\EntityManagerInterface;
19
use Symfony\Bundle\SecurityBundle\Security;
20
21
/**
22
 * Helper for progress/grade/certificate aggregated statistics,
23
 * reusable from API Platform controllers.
24
 */
25
class TrackingStatsHelper
26
{
27
    public function __construct(
28
        private readonly EntityManagerInterface $em,
29
        private readonly Security $security,
30
        private readonly CidReqHelper $cidReqHelper,
31
        private readonly CourseRepository $courseRepo,
32
        private readonly SessionRepository $sessionRepo,
33
        private readonly CLpRepository $lpRepo
34
    ) {}
35
36
    /**
37
     * Average learning path progress (0..100) for a user within a course/session.
38
     * Uses CLpRepository to fetch course LPs and their latest user progress.
39
     *
40
     * @return array{avg: float, count: int}
41
     */
42
    public function getUserAvgLpProgress(User $user, Course $course, ?Session $session): array
43
    {
44
        // Load all LPs for the course (optionally scoped by session); "published" filter kept by default.
45
        $qb  = $this->lpRepo->findAllByCourse($course, $session);
46
        $lps = $qb->getQuery()->getResult();
47
48
        if (!$lps) {
49
            return ['avg' => 0.0, 'count' => 0];
50
        }
51
52
        // Get the latest progress per LP for this user.
53
        //    Repository is expected to return a map for all LP ids (missing progress -> 0).
54
        $progressMap = $this->lpRepo->lastProgressForUser($lps, $user, $session);
55
        $count       = \count($progressMap);
56
        if ($count === 0) {
57
            return ['avg' => 0.0, 'count' => 0];
58
        }
59
60
        // Arithmetic mean across LPs (LPs without any view count as 0%).
61
        $sum = 0.0;
62
        foreach ($progressMap as $pct) {
63
            $sum += (float) $pct;
64
        }
65
66
        $avg = round($sum / $count, 2);
67
68
        return ['avg' => $avg, 'count' => $count];
69
    }
70
71
    /**
72
     * Certificates of a user within a course/session.
73
     *
74
     * @return array<int, array{id:int,title:string,issuedAt:string,downloadUrl:?string}>
75
     */
76
    public function getUserCertificates(User $user, Course $course, ?Session $session): array
77
    {
78
        // Locate the Gradebook Category that ties this course/session.
79
        $category = $this->em->getRepository(GradebookCategory::class)->findOneBy([
80
            'course'  => $course,
81
            'session' => $session, // will match NULL if $session is null
82
        ]);
83
84
        // If there is no category, there cannot be a course/session certificate.
85
        if (!$category) {
86
            return [];
87
        }
88
89
        // Read gradebook_certificate rows (DBAL keeps it simple even if there's no Doctrine entity).
90
        //    Expected columns: id, user_id, cat_id, created_at, path_certificate
91
        $conn = $this->em->getConnection();
92
        $rows = $conn->fetchAllAssociative(
93
            'SELECT id, created_at, path_certificate
94
             FROM gradebook_certificate
95
             WHERE user_id = :uid AND cat_id = :cat
96
             ORDER BY created_at DESC',
97
            ['uid' => $user->getId(), 'cat' => $category->getId()]
98
        );
99
100
        // Build a public-ish URL if possible (fallback to null if you serve via a controller).
101
        $title = $category->getTitle() ?? 'Course certificate';
102
103
        $out = [];
104
        foreach ($rows as $r) {
105
            $issuedAt = !empty($r['created_at'])
106
                ? (new \DateTime($r['created_at']))->format('c')
107
                : (new \DateTime())->format('c');
108
109
            $downloadUrl = $this->buildCertificateUrlFromPath($r['path_certificate'] ?? null);
110
111
            $out[] = [
112
                'id'          => (int) $r['id'],
113
                'title'       => $title,
114
                'issuedAt'    => $issuedAt,
115
                'downloadUrl' => $downloadUrl,
116
            ];
117
        }
118
119
        return $out;
120
    }
121
122
    /**
123
     * Build a public URL from a stored certificate path (legacy-compatible).
124
     * Return null if you prefer serving it through a Symfony controller.
125
     */
126
    private function buildCertificateUrlFromPath(?string $path): ?string
127
    {
128
        // Expected legacy format: "<hash>.html" placed under a public "certificates/" path.
129
        if (!$path) {
130
            return null;
131
        }
132
        $hash = pathinfo($path, PATHINFO_FILENAME);
133
        if (!$hash) {
134
            return null;
135
        }
136
        // If you have a Symfony route, replace the line below with $router->generate(...)
137
        return '/certificates/'.$hash.'.html';
0 ignored issues
show
Bug introduced by
Are you sure $hash of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

137
        return '/certificates/'./** @scrutinizer ignore-type */ $hash.'.html';
Loading history...
138
    }
139
140
    /**
141
     * Global gradebook score for a user within a course/session.
142
     *
143
     * @return array{score: float, max: float, percentage: float}
144
     */
145
    public function getUserGradebookGlobal(User $user, Course $course, ?Session $session): array
146
    {
147
        $qb = $this->em->createQueryBuilder()
148
            ->select('COALESCE(SUM(r.score), 0) AS score_sum', 'COALESCE(SUM(e.max), 0) AS max_sum')
149
            ->from(GradebookResult::class, 'r')
150
            ->innerJoin('r.evaluation', 'e')
151
            ->innerJoin('e.category', 'c')
152
            ->where('c.course = :course')
153
            ->andWhere('e.visible = 1')
154
            ->andWhere('c.visible = 1')
155
            ->andWhere('r.user = :user')
156
            ->setParameter('course', $course)
157
            ->setParameter('user', $user);
158
159
        if ($session) {
160
            $qb->andWhere('c.session = :session')->setParameter('session', $session);
161
        } else {
162
            $qb->andWhere('c.session IS NULL');
163
        }
164
165
        $row = $qb->getQuery()->getSingleResult();
166
        $score = (float) $row['score_sum'];
167
        $max   = (float) $row['max_sum'];
168
169
        if ($max <= 0.0) {
170
            return ['score' => 0.0, 'max' => 0.0, 'percentage' => 0.0];
171
        }
172
173
        $pct = ($score / $max) * 100.0;
174
175
        return [
176
            'score'      => round($score, 2),
177
            'max'        => round($max, 2),
178
            'percentage' => round($pct, 2),
179
        ];
180
    }
181
182
    /**
183
     * Average grade (0..100) across all participants for a course/session.
184
     *
185
     * @return array{avg: float, participants: int}
186
     */
187
    public function getCourseAverageScore(Course $course, ?Session $session): array
188
    {
189
        $participants = $this->getStudentParticipants($course, $session);
190
        $n = \count($participants);
191
        if ($n === 0) {
192
            return ['avg' => 0.0, 'participants' => 0];
193
        }
194
195
        $sumPct = 0.0;
196
        foreach ($participants as $user) {
197
            // Per-user: average score for tests/SCOs inside LPs of this course/session.
198
            $sumPct += $this->getUserAvgExerciseScore($user, $course, $session);
199
        }
200
201
        return ['avg' => round($sumPct / $n, 2), 'participants' => $n];
202
    }
203
204
    /**
205
     * User's average score (0..100) across LP tests/SCOs in a course/session.
206
     * Thin wrapper around legacy Tracking::get_avg_student_score.
207
     */
208
    private function getUserAvgExerciseScore(User $user, Course $course, ?Session $session): float
209
    {
210
        // Uses the legacy method (as seen in myStudents).
211
        $pct = \Tracking::get_avg_student_score(
212
            $user->getId(),
213
            $course,
214
            [],       // all LPs
215
            $session  // session (or null)
216
        );
217
218
        return is_numeric($pct) ? (float) $pct : 0.0;
219
    }
220
221
    public function getCourseAverageProgress(Course $course, ?Session $session): array
222
    {
223
        // Delegates to the fast variant.
224
        return $this->getCourseAverageProgressFast($course, $session);
225
    }
226
227
    /**
228
     * Fast average progress (0..100) for a course/session.
229
     * Counts ALL LPs in the course (even if the user never opened them),
230
     * using the latest CLpView per (user, lp).
231
     *
232
     * @return array{avg: float, participants: int}
233
     */
234
    public function getCourseAverageProgressFast(Course $course, ?Session $session): array
235
    {
236
        $participants = $this->getStudentParticipants($course, $session);
237
        $n = \count($participants);
238
        if ($n === 0) {
239
            return ['avg' => 0.0, 'participants' => 0];
240
        }
241
242
        // Make LP query consistent with getUserAvgLpProgress (published filter = true)
243
        $lps = $this->lpRepo->findAllByCourse($course, $session)
244
            ->getQuery()
245
            ->getResult();
246
247
        if (!$lps) {
248
            return ['avg' => 0.0, 'participants' => $n];
249
        }
250
251
        $lpIds = array_map(static fn($lp) => (int) $lp->getIid(), $lps);
252
        $lpCount = \count($lpIds);
253
254
        $qb = $this->em->createQueryBuilder();
255
        $qb->select('IDENTITY(v.user) AS uid', 'SUM(COALESCE(v.progress, 0)) AS sum_p')
256
            ->from(CLpView::class, 'v')
257
            ->where('IDENTITY(v.lp) IN (:lpIds)')
258
            ->andWhere($session ? 'v.session = :session' : 'v.session IS NULL')
259
            ->andWhere(
260
                'v.iid = (
261
                SELECT MAX(v2.iid) FROM ' . CLpView::class . ' v2
262
                WHERE v2.user = v.user AND v2.lp = v.lp ' .
263
                ($session ? 'AND v2.session = :session' : 'AND v2.session IS NULL') . '
264
            )'
265
            )
266
            ->groupBy('v.user')
267
            ->setParameter('lpIds', $lpIds);
268
269
        if ($session) {
270
            $qb->setParameter('session', $session);
271
        }
272
273
        $rows = $qb->getQuery()->getArrayResult();
274
        $sumByUser = [];
275
        foreach ($rows as $r) {
276
            $sumByUser[(int) $r['uid']] = (float) $r['sum_p'];
277
        }
278
279
        $totalAvg = 0.0;
280
        foreach ($participants as $user) {
281
            $userSum = $sumByUser[$user->getId()] ?? 0.0;
282
            $userAvg = $lpCount > 0 ? ($userSum / $lpCount) : 0.0;
283
            $totalAvg += $userAvg;
284
        }
285
286
        return ['avg' => round($totalAvg / $n, 2), 'participants' => $n];
287
    }
288
289
    /**
290
     * Returns student users for a course/session.
291
     *
292
     * @return User[]
293
     */
294
    private function getStudentParticipants(Course $course, ?Session $session): array
295
    {
296
        if ($session) {
297
            return $this->em->createQueryBuilder()
298
                ->select('DISTINCT u')
299
                ->from(User::class, 'u')
300
                ->innerJoin(SessionRelCourseRelUser::class, 'scru', 'WITH', 'scru.user = u')
301
                ->where('scru.course = :course')
302
                ->andWhere('scru.session = :session')
303
                ->andWhere('u.active = :active')
304
                ->setParameter('course', $course)
305
                ->setParameter('session', $session)
306
                ->setParameter('active', User::ACTIVE)
307
                ->getQuery()
308
                ->getResult();
309
        }
310
311
        $conn = $this->em->getConnection();
312
313
        $userIds = $conn->fetchFirstColumn(
314
            'SELECT DISTINCT user_id
315
         FROM course_rel_user
316
         WHERE c_id = :cid
317
         /* AND status = 0 */',
318
            ['cid' => (int) $course->getId()]
319
        );
320
321
        if (!$userIds) {
322
            $userIds = $conn->fetchFirstColumn(
323
                'SELECT DISTINCT user_id
324
             FROM session_rel_course_rel_user
325
             WHERE c_id = :cid AND (session_id = 0 OR session_id IS NULL)',
326
                ['cid' => (int) $course->getId()]
327
            );
328
        }
329
330
        if (!$userIds) {
331
            return [];
332
        }
333
334
        return $this->em->createQueryBuilder()
335
            ->select('u')
336
            ->from(User::class, 'u')
337
            ->where('u.id IN (:ids)')
338
            ->andWhere('u.active = :active')
339
            ->setParameter('ids', array_map('intval', $userIds))
340
            ->setParameter('active', User::ACTIVE)
341
            ->getQuery()
342
            ->getResult();
343
    }
344
}
345