Passed
Pull Request — master (#7085)
by Yannick
09:18
created

TrackingStatsHelper::getCourseVisits()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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