Passed
Pull Request — master (#7085)
by
unknown
10:19
created

TrackingStatsHelper::getCourseVisits()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 11
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 15
rs 9.9
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
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_COURSE_ACCESS);
0 ignored issues
show
Bug introduced by
The type Chamilo\CoreBundle\Helpers\Database was not found. Did you mean Database? If so, make sure to prefix the type with \.
Loading history...
82
        $sql = 'SELECT COUNT(course_access_id) FROM '.$table.' WHERE c_id = :cId';
83
        $params = ['cId' => $course->getId()];
84
        $types = ['cId' => ParameterType::INTEGER];
85
86
        if ($session !== null) {
87
            $sql .= ' AND session_id = :sessionId';
88
            $params['sessionId'] = $session->getId();
89
            $types['sessionId'] = ParameterType::INTEGER;
90
        }
91
        $count = $conn->fetchOne($sql, $params, $types);
92
        return (int) $count;
93
    }
94
95
    /**
96
     * Certificates of a user within a course/session.
97
     *
98
     * @return array<int, array{id:int,title:string,issuedAt:string,downloadUrl:?string}>
99
     */
100
    public function getUserCertificates(User $user, Course $course, ?Session $session): array
101
    {
102
        // Locate the Gradebook Category that ties this course/session.
103
        $category = $this->em->getRepository(GradebookCategory::class)->findOneBy([
104
            'course' => $course,
105
            'session' => $session, // will match NULL if $session is null
106
        ]);
107
108
        // If there is no category, there cannot be a course/session certificate.
109
        if (!$category) {
110
            return [];
111
        }
112
113
        // Read gradebook_certificate rows (DBAL keeps it simple even if there's no Doctrine entity).
114
        //    Expected columns: id, user_id, cat_id, created_at, path_certificate
115
        $conn = $this->em->getConnection();
116
        $rows = $conn->fetchAllAssociative(
117
            'SELECT id, created_at, path_certificate
118
             FROM gradebook_certificate
119
             WHERE user_id = :uid AND cat_id = :cat
120
             ORDER BY created_at DESC',
121
            ['uid' => $user->getId(), 'cat' => $category->getId()]
122
        );
123
124
        // Build a public-ish URL if possible (fallback to null if you serve via a controller).
125
        $title = $category->getTitle() ?? 'Course certificate';
126
127
        $out = [];
128
        foreach ($rows as $r) {
129
            $issuedAt = !empty($r['created_at'])
130
                ? (new DateTime($r['created_at']))->format('c')
131
                : (new DateTime())->format('c');
132
133
            $downloadUrl = $this->buildCertificateUrlFromPath($r['path_certificate'] ?? null);
134
135
            $out[] = [
136
                'id' => (int) $r['id'],
137
                'title' => $title,
138
                'issuedAt' => $issuedAt,
139
                'downloadUrl' => $downloadUrl,
140
            ];
141
        }
142
143
        return $out;
144
    }
145
146
    /**
147
     * Build a public URL from a stored certificate path (legacy-compatible).
148
     * Return null if you prefer serving it through a Symfony controller.
149
     */
150
    private function buildCertificateUrlFromPath(?string $path): ?string
151
    {
152
        // Expected legacy format: "<hash>.html" placed under a public "certificates/" path.
153
        if (!$path) {
154
            return null;
155
        }
156
        $hash = pathinfo($path, PATHINFO_FILENAME);
157
        if (!$hash) {
158
            return null;
159
        }
160
161
        // If you have a Symfony route, replace the line below with $router->generate(...)
162
        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

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