• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

uw-it-aca / canvas-analytics / 12014955326

25 Nov 2024 04:57PM UTC coverage: 93.713% (-0.1%) from 93.809%
12014955326

push

github

web-flow
Merge pull request #269 from uw-it-aca/develop

Develop

111 of 120 new or added lines in 6 files covered. (92.5%)

2 existing lines in 1 file now uncovered.

3801 of 4056 relevant lines covered (93.71%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.42
/data_aggregator/models.py
1
# Copyright 2024 UW-IT, University of Washington
2
# SPDX-License-Identifier: Apache-2.0
3

4

5
import os
1✔
6
import csv
1✔
7
import logging
1✔
8
from datetime import datetime, date, timedelta
1✔
9
from django.db import models, IntegrityError
1✔
10
from django.db.models import Q, Prefetch
1✔
11
from django.utils import timezone
1✔
12
from data_aggregator.exceptions import TermNotStarted
1✔
13
from data_aggregator import utilities
1✔
14
from uw_sws.term import get_term_by_date, get_term_by_year_and_quarter
1✔
15
from uw_sws import SWS_TIMEZONE
1✔
16

17

18
class TermManager(models.Manager):
1✔
19

20
    def get_term_for_sis_term_id(self, sis_term_id):
1✔
21
        """
22
        Return term for supplied sis_term_id
23

24
        :param sis_term_id: sis_term_id to return term for
25
        :type sis_term_id: str
26
        """
27
        term = (Term.objects
1✔
28
                .filter(sis_term_id=sis_term_id)).first()
29
        return term
1✔
30

31
    def get_term_for_date(self, date):
1✔
32
        """
33
        Return term intersecting with supplied date
34

35
        :param date: date to return term for
36
        :type date: datetime.datetime
37
        """
38
        term = (Term.objects
×
39
                .filter(first_day_quarter__lte=date)
40
                .filter(grade_submission_deadline__gte=date)).first()
41
        return term
×
42

43
    def get_or_create_term_from_sis_term_id(self, sis_term_id=None):
1✔
44
        """
45
        Creates and/or queries for Term matching sis_term_id. If sis_term_id
46
        is not defined, creates and/or queries for Term object for current
47
        sws term.
48

49
        :param sis_term_id: sis term id to return Term object for
50
        :type sis_term_id: str
51
        """
52
        if sis_term_id:
1✔
53
            # try to lookup the term in db for supplied sis_term_id
54
            term = self.get_term_for_sis_term_id(sis_term_id)
1✔
55
            if term:
1✔
56
                # return current term
57
                return term, False
1✔
58
            else:
59
                # lookup sws term object for supplied sis term id
60
                year, quarter = sis_term_id.split("-")
1✔
61
                sws_term = get_term_by_year_and_quarter(int(year), quarter)
1✔
62
                return self.get_or_create_from_sws_term(sws_term)
1✔
63
        else:
64
            # try to lookup the current term in db
65
            curr_date = timezone.now()
1✔
66
            term = self.get_term_for_date(curr_date)
1✔
67
            if term:
1✔
68
                # return current term
69
                return term, False
1✔
70
            else:
71
                # lookup sws term object for current term
72
                sws_term = get_term_by_date(curr_date.date())
1✔
73
                return self.get_or_create_from_sws_term(sws_term)
1✔
74

75
    def get_or_create_from_sws_term(self, sws_term):
1✔
76
        """
77
        Creates and/or queries for Term for sws_term object. If Term for
78
        sws_term is not defined in the database, a Term object is created.
79

80
        :param sws_term: sws_term object to create and or load
81
        :type sws_term: uw_sws.term
82
        """
83

84
        def sws_to_utc(dt):
1✔
85
            if isinstance(dt, date):
1✔
86
                # convert date to datetime
87
                dt = datetime.combine(dt, datetime.min.time())
1✔
88
                SWS_TIMEZONE.localize(dt)
1✔
89
                return dt.astimezone(timezone.utc)
1✔
90

91
        # get/create model for the term
92
        term, created = \
1✔
93
            Term.objects.get_or_create(sis_term_id=sws_term.canvas_sis_id())
94
        if created:
1✔
95
            # add current term info for course
96
            term.sis_term_id = sws_term.canvas_sis_id()
1✔
97
            term.year = sws_term.year
1✔
98
            term.quarter = sws_term.quarter
1✔
99
            term.label = sws_term.term_label()
1✔
100
            term.last_day_add = sws_to_utc(sws_term.last_day_add)
1✔
101
            term.last_day_drop = sws_to_utc(sws_term.last_day_drop)
1✔
102
            term.first_day_quarter = sws_to_utc(sws_term.first_day_quarter)
1✔
103
            term.census_day = sws_to_utc(sws_term.census_day)
1✔
104
            term.last_day_instruction = \
1✔
105
                sws_to_utc(sws_term.last_day_instruction)
106
            term.grading_period_open = sws_to_utc(sws_term.grading_period_open)
1✔
107
            term.aterm_grading_period_open = \
1✔
108
                sws_to_utc(sws_term.aterm_grading_period_open)
109
            term.grade_submission_deadline = \
1✔
110
                sws_to_utc(sws_term.grade_submission_deadline)
111
            term.last_final_exam_date = \
1✔
112
                sws_to_utc(sws_term.last_final_exam_date)
113
            term.save()
1✔
114
        return term, created
1✔
115

116

117
class Term(models.Model):
1✔
118

119
    objects = TermManager()
1✔
120
    sis_term_id = models.TextField(null=True)
1✔
121
    year = models.IntegerField(null=True)
1✔
122
    quarter = models.TextField(null=True)
1✔
123
    label = models.TextField(null=True)
1✔
124
    last_day_add = models.DateField(null=True)
1✔
125
    last_day_drop = models.DateField(null=True)
1✔
126
    first_day_quarter = models.DateField(null=True)
1✔
127
    census_day = models.DateField(null=True)
1✔
128
    last_day_instruction = models.DateField(null=True)
1✔
129
    grading_period_open = models.DateTimeField(null=True)
1✔
130
    aterm_grading_period_open = models.DateTimeField(null=True)
1✔
131
    grade_submission_deadline = models.DateTimeField(null=True)
1✔
132
    last_final_exam_date = models.DateTimeField(null=True)
1✔
133

134
    @property
1✔
135
    def term_number(self):
1✔
136
        return utilities.get_term_number(self.quarter)
×
137

138

139
class WeekManager(models.Manager):
1✔
140

141
    def get_or_create_week(self, sis_term_id=None, week_num=None):
1✔
142
        """
143
        Creates and/or queries for Week matching sis_term_id and week_num.
144
        If sis_term_id and/or week_num is not defined, creates and/or queries
145
        for Week object for current sws term and/or week_num.
146

147
        :param sis_term_id: sis term id to return Term object for
148
        :type sis_term_id: str
149
        :param week_num: week number to return Week object for
150
        :type week_num: int
151
        """
152
        term, _ = Term.objects.get_or_create_term_from_sis_term_id(
1✔
153
                                                    sis_term_id=sis_term_id)
154
        if week_num is None:
1✔
155
            # use current relative week number if not defined
156
            week_num = utilities.get_relative_week(term.first_day_quarter,
1✔
157
                                                   tz_name="US/Pacific")
158
        week, created = Week.objects.get_or_create(term=term, week=week_num)
1✔
159
        return week, created
1✔
160

161

162
class Week(models.Model):
1✔
163

164
    objects = WeekManager()
1✔
165
    term = models.ForeignKey(Term,
1✔
166
                             on_delete=models.CASCADE)
167
    week = models.IntegerField()
1✔
168

169
    @property
1✔
170
    def end_date(self):
1✔
171
        date = self.term.first_day_quarter
1✔
172
        week_count = 0
1✔
173
        while week_count < self.week:
1✔
174
            date += timedelta(days=6-utilities.get_rad_weekday(date))
1✔
175
            week_count += 1
1✔
176
            if week_count < self.week:
1✔
177
                date += timedelta(days=1)
1✔
178
        return date
1✔
179

180
    class Meta:
1✔
181
        unique_together = ('term', 'week',)
1✔
182

183

184
class Course(models.Model):
1✔
185

186
    canvas_course_id = models.BigIntegerField()
1✔
187
    sis_course_id = models.TextField(null=True)
1✔
188
    short_name = models.TextField(null=True)
1✔
189
    long_name = models.TextField(null=True)
1✔
190
    canvas_account_id = models.BigIntegerField(null=True)
1✔
191
    sis_account_id = models.TextField(null=True)
1✔
192
    status = models.TextField(null=True)
1✔
193
    term = models.ForeignKey(Term,
1✔
194
                             on_delete=models.CASCADE)
195

196

197
class User(models.Model):
1✔
198

199
    canvas_user_id = models.BigIntegerField(unique=True)
1✔
200
    login_id = models.TextField(null=True)
1✔
201
    sis_user_id = models.TextField(null=True)
1✔
202
    first_name = models.TextField(null=True)
1✔
203
    last_name = models.TextField(null=True)
1✔
204
    full_name = models.TextField(null=True)
1✔
205
    sortable_name = models.TextField(null=True)
1✔
206
    email = models.TextField(null=True)
1✔
207
    status = models.TextField(null=True)
1✔
208

209

210
class AdviserTypes():
1✔
211

212
    eop = "eop"
1✔
213
    iss = "iss"
1✔
214

215

216
class Adviser(models.Model):
1✔
217

218
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
219
    is_active = models.BooleanField(null=True)
1✔
220
    is_dept_adviser = models.BooleanField(null=True)
1✔
221
    full_name = models.TextField(null=True)
1✔
222
    pronouns = models.TextField(null=True)
1✔
223
    email_address = models.TextField(null=True)
1✔
224
    phone_number = models.TextField(null=True)
1✔
225
    uwnetid = models.TextField(null=True)
1✔
226
    regid = models.TextField(null=True)
1✔
227
    program = models.TextField(null=True)
1✔
228
    booking_url = models.TextField(null=True)
1✔
229
    metadata = models.TextField(null=True)
1✔
230
    timestamp = models.DateTimeField(null=True)
1✔
231

232

233
class JobManager(models.Manager):
1✔
234

235
    def get_jobs(self, jobtype):
1✔
236
        jobs = (self.get_queryset()
1✔
237
                .filter(type__type=jobtype))
238
        return jobs
1✔
239

240
    def get_active_jobs(self, jobtype):
1✔
241
        jobs = (self.get_jobs(jobtype)
1✔
242
                .filter(type__type=jobtype)
243
                .filter(target_date_end__gte=timezone.now())
244
                .filter(target_date_start__lte=timezone.now()))
245
        return jobs
1✔
246

247
    def get_pending_jobs(self, jobtype):
1✔
248
        jobs = (self.get_active_jobs(jobtype)
1✔
249
                .filter(pid=None))
250
        return jobs
1✔
251

252
    def get_running_jobs(self, jobtype):
1✔
253
        jobs = (self.get_jobs(jobtype)
1✔
254
                .filter(~Q(pid=None))  # running
255
                .filter(end=None)  # not completed
256
                .filter(message=''))  # not failed
257
        return jobs
1✔
258

259
    def get_running_jobs_for_term_week(self, jobtype, sis_term_id, week):
1✔
260
        jobs = self.get_running_jobs(jobtype)
1✔
261
        if jobtype in (AnalyticTypes.assignment,
1✔
262
                       AnalyticTypes.participation,
263
                       TaskTypes.build_subaccount_activity_report,
264
                       TaskTypes.export_subaccount_activity_report,
265
                       TaskTypes.create_rad_db_view,
266
                       TaskTypes.create_assignment_db_view,
267
                       TaskTypes.create_participation_db_view,
268
                       TaskTypes.create_rad_data_file):
269
            return (jobs.filter(context__sis_term_id=sis_term_id)
1✔
270
                        .filter(context__week=week))
271
        else:
272
            raise ValueError(f"Job type '{jobtype}'' does not have a  "
1✔
273
                             f"sis_term_id and week attribute.")
274

275
    def get_pending_or_running_jobs(self, jobtype):
1✔
276
        jobs = self.get_pending_jobs(jobtype) | self.get_running_jobs(jobtype)
1✔
277
        return jobs
1✔
278

279
    def claim_batch_of_jobs(self, jobtype, batchsize=None):
1✔
280
        # check for pending jobs to claim
281
        jobs = self.get_pending_jobs(jobtype)
1✔
282
        if jobs.count() == 0:
1✔
283
            # Check to see if we can instead reclaim jobs in case another
284
            # process crashed and left the db in a stale state. This only
285
            # works since there is only one daemon process per job type so
286
            # worker cronjobs aren't competing with each other.
287
            jobs = self.get_pending_or_running_jobs(jobtype)
1✔
288
            if jobs.count() > 0:
1✔
289
                logging.warning(f"Reclaiming {jobs.count()} jobs.")
1✔
290

291
        if batchsize is not None:
1✔
292
            jobs = jobs[:batchsize]
×
293

294
        for job in jobs:
1✔
295
            job.claim_job()
1✔
296

297
        return jobs
1✔
298

299
    def restart_jobs(self, job_ids, *args, **kwargs):
1✔
300
        jobs = self.filter(id__in=job_ids)
1✔
301
        for job in jobs:
1✔
302
            job.restart_job(*args, **kwargs)
1✔
303

304
    def clear_jobs(self, job_ids, *args, **kwargs):
1✔
305
        jobs = self.filter(id__in=job_ids)
×
306
        for job in jobs:
×
307
            job.clear_job(*args, **kwargs)
×
308

309

310
class AnalyticTypes():
1✔
311

312
    assignment = "assignment"
1✔
313
    participation = "participation"
1✔
314

315

316
class TaskTypes():
1✔
317

318
    create_terms = "create_terms"
1✔
319
    create_or_update_courses = "create_or_update_courses"
1✔
320
    create_or_update_users = "create_or_update_users"
1✔
321
    create_student_categories_data_file = "create_student_categories_data_file"
1✔
322
    reload_advisers = "reload_advisers"
1✔
323
    create_assignment_db_view = "create_assignment_db_view"
1✔
324
    create_participation_db_view = "create_participation_db_view"
1✔
325
    create_rad_db_view = "create_rad_db_view"
1✔
326
    create_rad_data_file = "create_rad_data_file"
1✔
327
    create_compass_db_view = "create_compass_db_view"
1✔
328
    create_compass_data_file = "create_compass_data_file"
1✔
329
    build_subaccount_activity_report = "build_subaccount_activity_report"
1✔
330
    export_subaccount_activity_report = "export_subaccount_activity_report"
1✔
331

332

333
class JobType(models.Model):
1✔
334

335
    JOB_CHOICES = (
1✔
336
        (AnalyticTypes.assignment, 'AssignmentJob'),
337
        (AnalyticTypes.participation, 'ParticipationJob'),
338
        (TaskTypes.create_terms, 'CreateTermsJob'),
339
        (TaskTypes.create_or_update_courses, 'CreateOrUpdateCoursesJob'),
340
        (TaskTypes.create_or_update_users, 'CreateOrUpdateUsersJob'),
341
        (TaskTypes.create_assignment_db_view, 'CreateAssignmentDBViewJob'),
342
        (TaskTypes.create_participation_db_view,
343
         'CreateParticipationDBViewJob'),
344
        (TaskTypes.create_rad_db_view, 'CreateRadDBViewJob'),
345
        (TaskTypes.create_rad_data_file, 'CreateRadDataFileJob'),
346
        (TaskTypes.create_compass_db_view, 'CreateCompassDBViewJob'),
347
        (TaskTypes.create_compass_data_file, 'CreateCompassDataFileJob'),
348
        (TaskTypes.create_student_categories_data_file,
349
         'CreateStudentCategoriesDataFileJob'))
350
    type = models.CharField(max_length=64, choices=JOB_CHOICES)
1✔
351

352

353
class JobStatusTypes():
1✔
354

355
    pending = "pending"
1✔
356
    claimed = "claimed"
1✔
357
    running = "running"
1✔
358
    completed = "completed"
1✔
359
    failed = "failed"
1✔
360
    expired = "expired"
1✔
361

362
    @classmethod
1✔
363
    def types(cls):
1✔
364
        return [JobStatusTypes.pending, JobStatusTypes.claimed,
×
365
                JobStatusTypes.running, JobStatusTypes.completed,
366
                JobStatusTypes.failed, JobStatusTypes.expired]
367

368

369
class Job(models.Model):
1✔
370

371
    objects = JobManager()
1✔
372
    type = models.ForeignKey(JobType,
1✔
373
                             on_delete=models.CASCADE)
374
    target_date_start = models.DateTimeField()
1✔
375
    target_date_end = models.DateTimeField()
1✔
376
    context = models.JSONField()
1✔
377
    pid = models.IntegerField(null=True)
1✔
378
    start = models.DateTimeField(null=True)
1✔
379
    end = models.DateTimeField(null=True)
1✔
380
    message = models.TextField()
1✔
381
    created = models.DateTimeField(auto_now_add=True)
1✔
382

383
    @staticmethod
1✔
384
    def get_default_target_start():
1✔
385
        return timezone.now()
1✔
386

387
    @staticmethod
1✔
388
    def get_default_target_end():
1✔
389
        now = timezone.now()
1✔
390
        tomorrow = now + timedelta(days=1)
1✔
391
        return tomorrow
1✔
392

393
    @property
1✔
394
    def status(self):
1✔
395
        # The order of these checks matters. We always want to display
396
        # completed, failed, aand running jobs, while pending and claimed
397
        # jobs may expire.
398
        if (self.pid and self.start and self.end and not self.message):
1✔
399
            return JobStatusTypes.completed
1✔
400
        elif (self.message):
1✔
401
            return JobStatusTypes.failed
1✔
402
        elif (self.pid and self.start and not self.end and not self.message):
1✔
403
            return JobStatusTypes.running
1✔
404
        elif self.target_date_end < timezone.now():
1✔
405
            return JobStatusTypes.expired
1✔
406
        elif (not self.pid and not self.start and not self.end and
1✔
407
                not self.message):
408
            return JobStatusTypes.pending
1✔
409
        elif (self.pid and not self.start and not self.end and
1✔
410
                not self.message):
411
            return JobStatusTypes.claimed
1✔
412

413
    def to_dict(self):
1✔
414
        return {
1✔
415
            "id": self.id,
416
            "type": self.type.type,
417
            "status": self.status,
418
            "target_date_start": self.target_date_start,
419
            "target_date_end": self.target_date_end,
420
            "context": self.context,
421
            "pid": self.pid,
422
            "start": self.start,
423
            "end": self.end,
424
            "message": self.message,
425
            "created": self.created
426
        }
427

428
    def claim_job(self, *args, **kwargs):
1✔
429
        self.pid = os.getpid()
1✔
430
        self.start = None
1✔
431
        self.end = None
1✔
432
        self.message = ''
1✔
433
        if kwargs.get("save", True) is True:
1✔
434
            super(Job, self).save(*args, **kwargs)
1✔
435

436
    def start_job(self, *args, **kwargs):
1✔
437
        if self.pid:
1✔
438
            self.start = timezone.now()
1✔
439
            self.end = None
1✔
440
            self.message = ''
1✔
441
            if kwargs.get("save", True) is True:
1✔
442
                super(Job, self).save(*args, **kwargs)
×
443
        else:
444
            raise RuntimeError("Trying to start a job that was never claimed "
1✔
445
                               "by a process. Unable to start a job that "
446
                               "doesn't have a set pid.")
447

448
    def end_job(self, *args, **kwargs):
1✔
449
        if self.pid and self.start:
1✔
450
            self.end = timezone.now()
1✔
451
            self.message = ''
1✔
452
            if kwargs.get("save", True) is True:
1✔
453
                super(Job, self).save(*args, **kwargs)
×
454
        else:
455
            raise RuntimeError("Trying to end a job that was never started "
×
456
                               "and/or claimed. Perhaps this was a running "
457
                               "job that was restarted.")
458

459
    def restart_job(self, *args, **kwargs):
1✔
460
        self.pid = None
1✔
461
        self.start = None
1✔
462
        self.end = None
1✔
463
        self.target_date_start = Job.get_default_target_start()
1✔
464
        self.target_date_end = Job.get_default_target_end()
1✔
465
        self.message = ""
1✔
466
        if kwargs.get("save", True) is True:
1✔
467
            super(Job, self).save(*args, **kwargs)
1✔
468

469

470
class AssignmentManager(models.Manager):
1✔
471

472
    def _map_assignment_data(self, assign, raw_assign_dict):
1✔
473
        assign.title = raw_assign_dict.get('title')
1✔
474
        assign.unlock_at = raw_assign_dict.get('unlock_at')
1✔
475
        assign.points_possible = raw_assign_dict.get('points_possible')
1✔
476
        assign.non_digital_submission = \
1✔
477
            raw_assign_dict.get('non_digital_submission')
478
        assign.due_at = raw_assign_dict.get('due_at')
1✔
479
        assign.status = raw_assign_dict.get('status')
1✔
480
        assign.muted = raw_assign_dict.get('muted')
1✔
481
        assign.max_score = raw_assign_dict.get('max_score')
1✔
482
        assign.min_score = raw_assign_dict.get('min_score')
1✔
483
        assign.first_quartile = raw_assign_dict.get('first_quartile')
1✔
484
        assign.median = raw_assign_dict.get('median')
1✔
485
        assign.third_quartile = raw_assign_dict.get('third_quartile')
1✔
486
        assign.excused = raw_assign_dict.get('excused')
1✔
487
        submission = raw_assign_dict.get('submission')
1✔
488
        if submission:
1✔
489
            assign.score = submission.get('score')
1✔
490
            assign.posted_at = submission.get('posted_at')
1✔
491
            assign.submitted_at = \
1✔
492
                submission.get('submitted_at')
493
        return assign
1✔
494

495
    def create_or_update_assignment(self, job, week, course, raw_assign_dict):
1✔
496
        created = True
1✔
497
        assignment_id = raw_assign_dict.get('assignment_id')
1✔
498
        student_id = raw_assign_dict.get('canvas_user_id')
1✔
499
        try:
1✔
500
            user = User.objects.get(canvas_user_id=student_id)
1✔
501
        except User.DoesNotExist:
×
502
            logging.warning(
×
503
                f"User with canvas_user_id {student_id} does not "
504
                f"exist in Canvas Analytics DB. Skipping.")
505
            return None, created
×
506
        assign = Assignment()
1✔
507
        assign.job = job
1✔
508
        assign.user = user
1✔
509
        assign.week = week
1✔
510
        assign.course = course
1✔
511
        assign.assignment_id = assignment_id
1✔
512
        assign = self._map_assignment_data(assign, raw_assign_dict)
1✔
513
        try:
1✔
514
            assign.save()
1✔
515
        except IntegrityError:
1✔
516
            # we update only if the unique contraint isn't satisified in order
517
            # to avoid checking for an assignment on every insert
518
            created = False
1✔
519
            assign = (Assignment.objects
1✔
520
                      .get(user=user,
521
                           assignment_id=assignment_id,
522
                           week=week))
523
            assign = self._map_assignment_data(assign, raw_assign_dict)
1✔
524
            assign.save()
1✔
525
            logging.warning(
1✔
526
                f"Found existing assignment entry for "
527
                f"canvas_course_id: {course.canvas_course_id}, "
528
                f"user: {user.canvas_user_id}, "
529
                f"sis-term-id: {week.term.sis_term_id}, "
530
                f"week: {week.week}")
531

532
        return assign, created
1✔
533

534

535
class Assignment(models.Model):
1✔
536

537
    objects = AssignmentManager()
1✔
538

539
    course = models.ForeignKey(Course,
1✔
540
                               on_delete=models.CASCADE)
541
    job = models.ForeignKey(Job,
1✔
542
                            on_delete=models.CASCADE)
543
    week = models.ForeignKey(Week,
1✔
544
                             on_delete=models.CASCADE)
545
    user = models.ForeignKey(User,
1✔
546
                             on_delete=models.CASCADE)
547
    assignment_id = models.IntegerField(null=True)
1✔
548
    title = models.TextField(null=True)
1✔
549
    unlock_at = models.DateTimeField(null=True)
1✔
550
    points_possible = \
1✔
551
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
552
    non_digital_submission = models.BooleanField(null=True)
1✔
553
    due_at = models.DateTimeField(null=True)
1✔
554
    status = models.TextField(null=True)
1✔
555
    muted = models.BooleanField(null=True)
1✔
556
    min_score = models.DecimalField(null=True, max_digits=13, decimal_places=3)
1✔
557
    max_score = models.DecimalField(null=True, max_digits=13, decimal_places=3)
1✔
558
    first_quartile = models.IntegerField(null=True)
1✔
559
    median = models.IntegerField(null=True)
1✔
560
    third_quartile = models.IntegerField(null=True)
1✔
561
    excused = models.BooleanField(null=True)
1✔
562
    score = models.DecimalField(null=True, max_digits=13, decimal_places=3)
1✔
563
    posted_at = models.DateTimeField(null=True)
1✔
564
    submitted_at = models.DateTimeField(null=True)
1✔
565

566
    class Meta:
1✔
567
        constraints = [
1✔
568
            models.UniqueConstraint(fields=['user', 'course', 'assignment_id',
569
                                            'week'],
570
                                    name='unique_assignment')
571
        ]
572

573

574
class ParticipationManager(models.Manager):
1✔
575

576
    def _map_participation_data(self, partic, raw_partic_dict):
1✔
577
        partic.page_views = raw_partic_dict.get('page_views')
1✔
578
        partic.max_page_views = raw_partic_dict.get('max_page_views')
1✔
579
        partic.page_views_level = \
1✔
580
            raw_partic_dict.get('page_views_level')
581
        partic.participations = raw_partic_dict.get('participations')
1✔
582
        partic.max_participations = raw_partic_dict.get('max_participations')
1✔
583
        partic.participations_level = \
1✔
584
            raw_partic_dict.get('participations_level')
585
        if raw_partic_dict.get('tardiness_breakdown'):
1✔
586
            partic.time_total = (raw_partic_dict.get('tardiness_breakdown')
1✔
587
                                 .get('total'))
588
            partic.time_on_time = (raw_partic_dict.get('tardiness_breakdown')
1✔
589
                                   .get('on_time'))
590
            partic.time_late = (raw_partic_dict.get('tardiness_breakdown')
1✔
591
                                .get('late'))
592
            partic.time_missing = (raw_partic_dict.get('tardiness_breakdown')
1✔
593
                                   .get('missing'))
594
            partic.time_floating = (raw_partic_dict.get('tardiness_breakdown')
1✔
595
                                    .get('floating'))
596
        return partic
1✔
597

598
    def create_or_update_participation(self, job, week, course,
1✔
599
                                       raw_partic_dict):
600
        created = True
1✔
601
        student_id = raw_partic_dict.get('canvas_user_id')
1✔
602
        try:
1✔
603
            user = User.objects.get(canvas_user_id=student_id)
1✔
604
        except User.DoesNotExist:
×
605
            logging.warning(
×
606
                f"User with canvas_user_id {student_id} does not "
607
                f"exist in Canvas Analytics DB. Skipping.")
608
            return None, created
×
609
        partic = Participation()
1✔
610
        partic.job = job
1✔
611
        partic.user = user
1✔
612
        partic.week = week
1✔
613
        partic.course = course
1✔
614
        partic = self._map_participation_data(partic, raw_partic_dict)
1✔
615
        try:
1✔
616
            partic.save()
1✔
617
        except IntegrityError:
1✔
618
            # we update only if the unique contraint isn't satisified in order
619
            # to avoid checking for a participation on every insert
620
            created = False
1✔
621
            partic = (Participation.objects.get(user=user,
1✔
622
                                                week=week,
623
                                                course=course))
624
            partic = self._map_participation_data(partic, raw_partic_dict)
1✔
625
            partic.save()
1✔
626
            logging.warning(
1✔
627
                f"Found existing participation entry for "
628
                f"canvas_course_id: {course.canvas_course_id}, "
629
                f"user: {user.canvas_user_id}, "
630
                f"sis-term-id: {week.term.sis_term_id}, "
631
                f"week: {week.week}")
632
        return partic, created
1✔
633

634

635
class Participation(models.Model):
1✔
636

637
    objects = ParticipationManager()
1✔
638

639
    course = models.ForeignKey(Course,
1✔
640
                               on_delete=models.CASCADE)
641
    job = models.ForeignKey(Job,
1✔
642
                            on_delete=models.CASCADE)
643
    week = models.ForeignKey(Week,
1✔
644
                             on_delete=models.CASCADE)
645
    user = models.ForeignKey(User,
1✔
646
                             on_delete=models.CASCADE)
647
    page_views = models.IntegerField(null=True)
1✔
648
    max_page_views = models.IntegerField(null=True)
1✔
649
    page_views_level = models.IntegerField(null=True)
1✔
650
    participations = models.IntegerField(null=True)
1✔
651
    max_participations = models.IntegerField(null=True)
1✔
652
    participations_level = models.IntegerField(null=True)
1✔
653
    time_total = models.IntegerField(null=True)
1✔
654
    time_on_time = models.IntegerField(null=True)
1✔
655
    time_late = models.IntegerField(null=True)
1✔
656
    time_missing = models.IntegerField(null=True)
1✔
657
    time_floating = models.IntegerField(null=True)
1✔
658

659
    class Meta:
1✔
660
        constraints = [
1✔
661
            models.UniqueConstraint(fields=['user', 'course', 'week'],
662
                                    name='unique_participation')
663
        ]
664

665

666
class RadDbView(models.Model):
1✔
667

668
    class Meta:
1✔
669
        abstract = True
1✔
670

671
    @classmethod
1✔
672
    def setDb_table(Class, tableName):
1✔
673
        class Meta:
1✔
674
            managed = False
1✔
675
            db_table = tableName
1✔
676

677
        attrs = {
1✔
678
            '__module__': Class.__module__,
679
            'Meta': Meta
680
        }
681
        return type(tableName, (Class,), attrs)
1✔
682

683
    canvas_user_id = models.BigIntegerField(unique=True, primary_key=True)
1✔
684
    full_name = models.TextField(null=True)
1✔
685
    term = models.TextField(null=True)
1✔
686
    week = models.IntegerField()
1✔
687
    assignment_score = \
1✔
688
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
689
    participation_score = \
1✔
690
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
691
    grade = \
1✔
692
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
693

694

695
class CompassDbView(models.Model):
1✔
696
    class Meta:
1✔
697
        abstract = True
1✔
698

699
    @classmethod
1✔
700
    def setDb_table(Class, tableName):
1✔
701
        class Meta:
×
702
            managed = False
×
703
            db_table = tableName
×
704

705
        attrs = {
×
706
            '__module__': Class.__module__,
707
            'Meta': Meta
708
        }
709
        return type(tableName, (Class,), attrs)
×
710

711
    canvas_user_id = models.BigIntegerField(unique=True, primary_key=True)
1✔
712
    full_name = models.TextField(null=True)
1✔
713
    term = models.TextField(null=True)
1✔
714
    week = models.IntegerField()
1✔
715
    normalized_assignment_score = \
1✔
716
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
717
    normalized_participation_score = \
1✔
718
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
719
    normalized_user_course_percentage = \
1✔
720
        models.DecimalField(null=True, max_digits=13, decimal_places=3)
721
    course_id = models.TextField(null=True)
1✔
722

723

724
class ReportManager(models.Manager):
1✔
725
    def get_or_create_report(self, report_type,
1✔
726
                             sis_term_id=None, week_num=None):
727
        term, _ = Term.objects.get_or_create_term_from_sis_term_id(
1✔
728
            sis_term_id=sis_term_id)
729
        week, _ = Week.objects.get_or_create_week(
1✔
730
            sis_term_id=term.sis_term_id,
731
            week_num=week_num)
732

733
        if week.week < 1:
1✔
734
            raise TermNotStarted(term.sis_term_id)
×
735

736
        started_dt = datetime.utcnow().replace(tzinfo=timezone.utc)
1✔
737
        report, report_created = Report.objects.get_or_create(
1✔
738
            report_type=report_type,
739
            term_id=term.sis_term_id,
740
            term_week=week.week, defaults={"started_date": started_dt})
741

742
        if not report_created:
1✔
743
            # re-running an existing report, so flush existing data
744
            SubaccountActivity.objects.filter(report=report).delete()
×
745

746
            # reset dates
747
            report.started_date = started_dt
×
748
            report.finished_date = None
×
749
            report.save()
×
750

751
        return report
1✔
752

753
    def get_subaccount_activity(self, sis_term_id=None, week_num=None):
1✔
754
        kwargs = {
1✔
755
            "report_type": Report.SUBACCOUNT_ACTIVITY,
756
            "finished_date__isnull": False,
757
            "term_week__isnull": False,
758
        }
759
        if sis_term_id is not None:
1✔
760
            kwargs["term_id"] = sis_term_id
1✔
761
            if week_num is not None:
1✔
762
                kwargs["term_week"] = week_num
1✔
763

764
        prefetch = Prefetch(
1✔
765
            "subaccountactivity_set",
766
            queryset=SubaccountActivity.objects.filter(
767
                Q(subaccount_id__startswith="uwcourse")
768
                    ).order_by("subaccount_id"),
769
            to_attr="subaccounts")
770

771
        return super().get_queryset().prefetch_related(prefetch).filter(
1✔
772
            **kwargs).order_by("-finished_date")
773

774

775
class Report(models.Model):
1✔
776
    """
777
    Represents a report
778
    """
779
    objects = ReportManager()
1✔
780

781
    class Meta:
1✔
782
        db_table = "analytics_report"
1✔
783

784
    SUBACCOUNT_ACTIVITY = "subaccount_activity"
1✔
785

786
    TYPE_CHOICES = (
1✔
787
        (SUBACCOUNT_ACTIVITY, "SubAccount Activity"),
788
    )
789

790
    report_type = models.CharField(max_length=80, choices=TYPE_CHOICES)
1✔
791
    started_date = models.DateTimeField()
1✔
792
    finished_date = models.DateTimeField(null=True)
1✔
793
    term_id = models.CharField(max_length=20)
1✔
794
    term_week = models.PositiveIntegerField(null=True)
1✔
795

796
    def finished(self):
1✔
797
        self.finished_date = datetime.utcnow().replace(tzinfo=timezone.utc)
1✔
798
        self.save()
1✔
799

800
    def subaccount_activity_header(self):
1✔
801
        return [
1✔
802
            "term_sis_id", "week_num", "subaccount_id", "subaccount_name",
803
            "campus", "college", "department", "adoption_rate", "courses",
804
            "active_courses", "ind_study_courses", "active_ind_study_courses",
805
            "xlist_courses", "xlist_ind_study_courses", "teachers",
806
            "unique_teachers", "students", "unique_students",
807
            "discussion_topics", "discussion_replies", "media_objects",
808
            "attachments", "assignments", "submissions", "announcements_views",
809
            "assignments_views", "collaborations_views", "conferences_views",
810
            "discussions_views", "files_views", "general_views",
811
            "grades_views", "groups_views", "modules_views", "other_views",
812
            "pages_views", "quizzes_views"
813
        ]
814

815

816
class SubaccountActivity(models.Model):
1✔
817
    """
818
    Represents activity by sub-account and term
819
    """
820

821
    class Meta:
1✔
822
        db_table = "analytics_subaccountactivity"
1✔
823

824
    report = models.ForeignKey(Report, on_delete=models.CASCADE)
1✔
825
    term_id = models.CharField(max_length=20)
1✔
826
    subaccount_id = models.CharField(max_length=100)
1✔
827
    subaccount_name = models.CharField(max_length=200)
1✔
828
    courses = models.PositiveIntegerField(default=0)
1✔
829
    active_courses = models.PositiveIntegerField(default=0)
1✔
830
    ind_study_courses = models.PositiveIntegerField(default=0)
1✔
831
    active_ind_study_courses = models.PositiveIntegerField(default=0)
1✔
832
    xlist_courses = models.PositiveIntegerField(default=0)
1✔
833
    xlist_ind_study_courses = models.PositiveIntegerField(default=0)
1✔
834
    teachers = models.PositiveIntegerField(default=0)
1✔
835
    unique_teachers = models.PositiveIntegerField(default=0)
1✔
836
    students = models.PositiveIntegerField(default=0)
1✔
837
    unique_students = models.PositiveIntegerField(default=0)
1✔
838
    discussion_topics = models.PositiveIntegerField(default=0)
1✔
839
    discussion_replies = models.PositiveIntegerField(default=0)
1✔
840
    media_objects = models.PositiveIntegerField(default=0)
1✔
841
    attachments = models.PositiveIntegerField(default=0)
1✔
842
    assignments = models.PositiveIntegerField(default=0)
1✔
843
    submissions = models.PositiveIntegerField(default=0)
1✔
844
    announcements_views = models.PositiveIntegerField(default=0)
1✔
845
    assignments_views = models.PositiveIntegerField(default=0)
1✔
846
    collaborations_views = models.PositiveIntegerField(default=0)
1✔
847
    conferences_views = models.PositiveIntegerField(default=0)
1✔
848
    discussions_views = models.PositiveIntegerField(default=0)
1✔
849
    files_views = models.PositiveIntegerField(default=0)
1✔
850
    general_views = models.PositiveIntegerField(default=0)
1✔
851
    grades_views = models.PositiveIntegerField(default=0)
1✔
852
    groups_views = models.PositiveIntegerField(default=0)
1✔
853
    modules_views = models.PositiveIntegerField(default=0)
1✔
854
    other_views = models.PositiveIntegerField(default=0)
1✔
855
    pages_views = models.PositiveIntegerField(default=0)
1✔
856
    quizzes_views = models.PositiveIntegerField(default=0)
1✔
857

858
    def adoption_rate(self):
1✔
859
        courses = self.courses or 0
1✔
860
        active_courses = self.active_courses or 0
1✔
861
        ind_study_courses = self.ind_study_courses or 0
1✔
862
        active_ind_study_courses = self.active_ind_study_courses or 0
1✔
863
        xlist_courses = self.xlist_courses or 0
1✔
864
        xlist_ind_study_courses = self.xlist_ind_study_courses or 0
1✔
865

866
        try:
1✔
867
            rate = round(
1✔
868
                ((active_courses - active_ind_study_courses) /
869
                    (courses - xlist_courses - ind_study_courses -
870
                        xlist_ind_study_courses)) * 100, ndigits=2)
NEW
871
        except ZeroDivisionError:
×
NEW
872
            rate = 0.00
×
873

874
        return rate
1✔
875

876
    def csv_export_data(self):
1✔
877
        accounts = self.subaccount_id.split(":")
1✔
878
        return [
1✔
879
            self.report.term_id,
880
            self.report.term_week,
881
            self.subaccount_id,
882
            self.subaccount_name,
883
            accounts[1] if 1 < len(accounts) else None,
884
            accounts[2] if 2 < len(accounts) else None,
885
            accounts[3] if 3 < len(accounts) else None,
886
            self.adoption_rate(),
887
            self.courses or 0,
888
            self.active_courses or 0,
889
            self.ind_study_courses or 0,
890
            self.active_ind_study_courses or 0,
891
            self.xlist_courses or 0,
892
            self.xlist_ind_study_courses or 0,
893
            self.teachers or 0,
894
            self.unique_teachers or 0,
895
            self.students or 0,
896
            self.unique_students or 0,
897
            self.discussion_topics or 0,
898
            self.discussion_replies or 0,
899
            self.media_objects or 0,
900
            self.attachments or 0,
901
            self.assignments or 0,
902
            self.submissions or 0,
903
            self.announcements_views or 0,
904
            self.assignments_views or 0,
905
            self.collaborations_views or 0,
906
            self.conferences_views or 0,
907
            self.discussions_views or 0,
908
            self.files_views or 0,
909
            self.general_views or 0,
910
            self.grades_views or 0,
911
            self.groups_views or 0,
912
            self.modules_views or 0,
913
            self.other_views or 0,
914
            self.pages_views or 0,
915
            self.quizzes_views or 0,
916
        ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc