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

tjcsl / tin / 17450891849

04 Sep 2025 01:49AM UTC coverage: 58.858% (+0.2%) from 58.654%
17450891849

Pull #92

github

web-flow
Merge 8c9f1d1d3 into 564991635
Pull Request #92: Allow choosing custom versions of python for graders

470 of 971 branches covered (48.4%)

Branch coverage included in aggregate %.

59 of 81 new or added lines in 12 files covered. (72.84%)

1 existing line in 1 file now uncovered.

1746 of 2794 relevant lines covered (62.49%)

0.62 hits per line

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

63.68
/tin/apps/assignments/models.py
1
from __future__ import annotations
1✔
2

3
import datetime
1✔
4
import logging
1✔
5
import os
1✔
6
import subprocess
1✔
7
from pathlib import Path
1✔
8
from typing import Literal
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth import get_user_model
1✔
12
from django.core.exceptions import ValidationError
1✔
13
from django.core.validators import MinValueValidator
1✔
14
from django.db import models, transaction
1✔
15
from django.db.models import Q
1✔
16
from django.urls import reverse
1✔
17
from django.utils import timezone
1✔
18

19
from ...sandboxing import get_action_sandbox_args, get_assignment_sandbox_args
1✔
20
from ..courses.models import Course, Period
1✔
21
from ..submissions.models import Submission
1✔
22
from ..venvs.models import Venv
1✔
23

24
logger = logging.getLogger(__name__)
1✔
25

26

27
class Folder(models.Model):
1✔
28
    """A folder for assignments.
29

30
    Each course can have multiple folders, and each
31
    assignment can be in a folder.
32
    """
33

34
    name = models.CharField(max_length=50)
1✔
35

36
    course = models.ForeignKey("courses.Course", on_delete=models.CASCADE, related_name="folders")
1✔
37

38
    def __str__(self):
1✔
39
        return self.name
×
40

41
    def __repr__(self):
42
        return self.name
43

44
    def get_absolute_url(self):
1✔
45
        return reverse("assignments:show_folder", args=[self.course.id, self.id])
×
46

47

48
class AssignmentQuerySet(models.query.QuerySet):
1✔
49
    def filter_permissions(self, user, *perms: Literal["-", "r", "w"]):
1✔
50
        """Filters based off of the permissions of the user and the course.
51

52
        An admin can always see everything. A teacher can only see courses they
53
        are the teachers for. Otherwise it filters it based off the course being archived
54
        and/or the permission of the course after archiving.
55

56
        Args:
57
            user: The user executing the query (``request.user``)
58
            *perms: Every permission listed is or-ed together.
59
                Each value can be - (hidden), r (read-only), or w (read-write)
60
        """
61
        if user.is_superuser:
1!
62
            return self.all()
×
63
        else:
64
            perm_q = Q(course__archived=False)
1✔
65
            for perm in perms:
1✔
66
                perm_q |= Q(course__permission=perm)
1✔
67
            q = Q(course__teacher=user) | (Q(course__students=user, hidden=False) & perm_q)
1✔
68

69
            return self.filter(q).distinct()
1✔
70

71
    def filter_visible(self, user):
1✔
72
        r"""Filters assignments that are visible to a user
73

74
        Alias for calling :meth:`filter_permissions` with the permissions
75
        "r" and "w"
76
        """
77
        return self.filter_permissions(user, "r", "w")
1✔
78

79
    def filter_submittable(self, user):
1✔
80
        """Filters by assignments that can be submitted.
81

82
        .. warning::
83

84
            Do NOT use this if :attr:`~Assignment.is_quiz` is ``True``.
85
            In that case, the check should be done manually.
86
        """
87
        return self.filter_permissions(user, "w")
1✔
88

89
    def filter_editable(self, user):
1✔
90
        """Filters assignments if they're editable by the user"""
91
        if user.is_superuser:
1!
92
            return self.all()
×
93
        else:
94
            return self.filter(course__teacher=user).distinct()
1✔
95

96

97
def upload_grader_file_path(assignment, _):  # pylint: disable=unused-argument
1✔
98
    """Get the location of the grader file for an assignment"""
99
    assert assignment.id is not None
1✔
100
    if assignment.grader_language == "J":
1!
UNCOV
101
        return f"assignment-{assignment.id}/Grader.java"
×
102
    else:
103
        return f"assignment-{assignment.id}/grader.py"
1✔
104

105

106
class Assignment(models.Model):
1✔
107
    """An assignment (or quiz) for a student.
108

109
    If :attr:`~.Assignment.is_quiz` is ``True``, this
110
    model doubles as a quiz.
111

112
    The manager for this model is :class:`.AssignmentQuerySet`.
113
    """
114

115
    name = models.CharField(max_length=50)
1✔
116
    folder = models.ForeignKey(
1✔
117
        Folder,
118
        on_delete=models.SET_NULL,
119
        default=None,
120
        null=True,
121
        blank=True,
122
        related_name="assignments",
123
    )
124
    description = models.CharField(max_length=4096)
1✔
125
    markdown = models.BooleanField(default=False)
1✔
126

127
    language_details = models.ForeignKey(
1✔
128
        "Language",
129
        on_delete=models.CASCADE,
130
        related_name="assignment_set",
131
        null=False,
132
    )
133

134
    filename = models.CharField(max_length=50, default="main.py")
1✔
135

136
    course = models.ForeignKey(
1✔
137
        "courses.Course", on_delete=models.CASCADE, related_name="assignments"
138
    )
139

140
    venv = models.ForeignKey(
1✔
141
        Venv,
142
        on_delete=models.SET_NULL,
143
        default=None,
144
        null=True,
145
        blank=True,
146
        related_name="assignments",
147
    )
148

149
    points_possible = models.DecimalField(
1✔
150
        max_digits=6, decimal_places=3, validators=[MinValueValidator(1)]
151
    )
152

153
    assigned = models.DateTimeField(auto_now_add=True)
1✔
154
    due = models.DateTimeField()
1✔
155
    hidden = models.BooleanField(default=False)
1✔
156

157
    grader_file = models.FileField(upload_to=upload_grader_file_path, null=True)
1✔
158
    enable_grader_timeout = models.BooleanField(default=True)
1✔
159
    grader_timeout = models.IntegerField(default=300, validators=[MinValueValidator(10)])
1✔
160

161
    grader_has_network_access = models.BooleanField(default=False)
1✔
162

163
    has_network_access = models.BooleanField(default=False)
1✔
164

165
    # WARNING: this is the rate limit
166
    submission_limit_count = models.PositiveIntegerField(
1✔
167
        default=90,
168
        validators=[MinValueValidator(10)],
169
    )
170
    submission_limit_interval = models.PositiveIntegerField(
1✔
171
        default=30,
172
        validators=[MinValueValidator(10)],
173
    )
174
    submission_limit_cooldown = models.PositiveIntegerField(
1✔
175
        default=30,
176
        validators=[MinValueValidator(10)],
177
    )
178

179
    last_action_output = models.CharField(max_length=16 * 1024, default="", null=False, blank=True)
1✔
180

181
    is_quiz = models.BooleanField(default=False)
1✔
182
    QUIZ_ACTIONS = (("0", "Log only"), ("1", "Color Change"), ("2", "Lock"))
1✔
183
    quiz_action = models.CharField(max_length=1, choices=QUIZ_ACTIONS, default="2")
1✔
184
    quiz_autocomplete_enabled = models.BooleanField(default=False)
1✔
185
    quiz_description = models.CharField(max_length=4096, default="", null=False, blank=True)
1✔
186
    quiz_description_markdown = models.BooleanField(default=False)
1✔
187

188
    objects = AssignmentQuerySet.as_manager()
1✔
189

190
    submission_caps: models.QuerySet[SubmissionCap]
1✔
191

192
    def __str__(self):
1✔
193
        return self.name
×
194

195
    def __repr__(self):
196
        return self.name
197

198
    def get_absolute_url(self):
1✔
199
        return reverse("assignments:show", args=(self.id,))
×
200

201
    @property
1✔
202
    def grader_language(self) -> Literal["P", "J"]:
1✔
203
        """The language of the assignment (Python or Java)"""
204
        return self.language_details.language
1✔
205

206
    def within_submission_limit(self, student) -> bool:
1✔
207
        """Check if a student is within the submission limit for an assignment."""
208
        # teachers should have infinite submissions
209
        if not student.is_student or self.is_quiz or not self.submission_caps.exists():
1✔
210
            return True
1✔
211

212
        # note that this doesn't care about killed/incomplete submissions
213
        submission_count = self.submissions.filter(student=student).count()
1✔
214

215
        cap = self.find_submission_cap(student)
1✔
216
        return submission_count < cap
1✔
217

218
    def find_submission_cap(self, student) -> float:
1✔
219
        """Given a student, find the submission cap.
220

221
        This takes into account student overrides, and due dates.
222
        """
223
        if student.is_superuser or student.is_teacher:
1✔
224
            return float("inf")
1✔
225
        if timezone.localtime() > self.due:
1✔
226
            return self.submission_cap_after_due(student)
1✔
227
        return self.before_submission_cap(student)
1✔
228

229
    def find_student_override(self, student) -> SubmissionCap | None:
1✔
230
        """Find an :class:`.SubmissionCap` for a student.
231

232
        Returns ``None`` if no override exists.
233
        """
234
        return self.submission_caps.filter(student=student).first()
1✔
235

236
    def before_submission_cap(self, student) -> float:
1✔
237
        """Get the submission cap for an assignment before the due date.
238

239
        Returns ``float("inf")`` if no cap is found.
240
        """
241
        student_cap = self.find_student_override(student)
1✔
242
        if student_cap is not None and student_cap.submission_cap is not None:
1✔
243
            return student_cap.submission_cap
1✔
244
        cap = self.submission_caps.filter(student__isnull=True).first()
1✔
245
        if cap is not None and cap.submission_cap is not None:
1✔
246
            return cap.submission_cap
1✔
247
        return float("inf")
1✔
248

249
    def submission_cap_after_due(self, student) -> float:
1✔
250
        """Get the submission cap after the due date.
251

252
        Returns ``float("inf")`` if no cap is found.
253
        """
254
        student_cap = self.find_student_override(student)
1✔
255
        if student_cap is not None:
1✔
256
            # at least one of these must be set because of the model constraints
257
            return (
1✔
258
                student_cap.submission_cap_after_due
259
                if student_cap.submission_cap_after_due is not None
260
                else student_cap.submission_cap
261
            )
262
        cap = self.submission_caps.filter(student__isnull=True).first()
1✔
263
        if cap is not None and cap.submission_cap_after_due is not None:
1✔
264
            return cap.submission_cap_after_due
1✔
265
        # fall back to the submission cap before the due date
266
        return self.before_submission_cap(student)
1✔
267

268
    def make_assignment_dir(self) -> None:
1✔
269
        """Creates the directory where the assignment grader scripts go."""
270
        assignment_path = os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}")
1✔
271
        os.makedirs(assignment_path, exist_ok=True)
1✔
272

273
    def grader_exists(self) -> bool:
1✔
274
        """Check if a grader file exists."""
275
        if self.grader_file is None or self.grader_file.name is None:
1!
NEW
276
            return False
×
277

278
        fpath = Path(settings.MEDIA_ROOT) / self.grader_file.name
1✔
279
        return fpath.exists()
1✔
280

281
    def save_grader_file(self, grader_text: str) -> None:
1✔
282
        """Save the grader file to the correct location.
283

284
        .. warning::
285

286
            Writing to files in directories not controlled by us without some
287
            form of sandboxing is a security risk. Most notably, users can use symbolic
288
            links to trick you into writing to another file, outside the directory.
289
            they control.
290
            This solution is very hacky, but we don't have another good way of
291
            doing this.
292
        """
293
        fname = upload_grader_file_path(self, "")
1✔
294

295
        self.grader_file.name = fname
1✔
296
        self.save()
1✔
297

298
        fpath = os.path.join(settings.MEDIA_ROOT, self.grader_file.name)
1✔
299

300
        os.makedirs(os.path.dirname(fpath), exist_ok=True)
1✔
301

302
        args = get_assignment_sandbox_args(
1✔
303
            ["sh", "-c", 'cat >"$1"', "sh", fpath],
304
            network_access=False,
305
            whitelist=[os.path.dirname(fpath)],
306
        )
307

308
        try:
1✔
309
            subprocess.run(
1✔
310
                args,
311
                input=grader_text,
312
                stdout=subprocess.DEVNULL,
313
                stderr=subprocess.PIPE,
314
                encoding="utf-8",
315
                text=True,
316
                check=True,
317
            )
318
        except FileNotFoundError as e:
×
319
            logger.error("Cannot run processes: %s", e)
×
320
            raise FileNotFoundError from e
×
321

322
    def list_files(self) -> list[tuple[int, str, str, int, datetime.datetime]]:
1✔
323
        """List all files in the assignments directory
324

325
        Returns:
326
            - The index of the assignment
327
            - The name of the assignment submission
328
            - The full path to the assignment submission
329
            - The size of the submission
330
            - The time at which it was submitted
331
        """
332
        self.make_assignment_dir()
1✔
333

334
        assignment_path = os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}")
1✔
335

336
        files = []
1✔
337
        grader_file = upload_grader_file_path(self, "")
1✔
338
        grader_log_file = self.grader_log_filename
1✔
339

340
        for i, item in enumerate(os.scandir(assignment_path)):
1!
341
            if item.is_file():
×
342
                stat = item.stat(follow_symlinks=False)
×
343
                item_details = (
×
344
                    i,
345
                    item.name,
346
                    item.path,
347
                    stat.st_size,
348
                    datetime.datetime.fromtimestamp(stat.st_mtime),
349
                )
350
                if not grader_file.endswith(item.name) and not grader_log_file.endswith(item.name):
×
351
                    files.append(item_details)
×
352

353
        return files
1✔
354

355
    def save_file(self, file_text: str | bytes, file_name: str) -> None:
1✔
356
        """Save some text as a file"""
357
        self.make_assignment_dir()
1✔
358

359
        fpath = os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}", file_name)
1✔
360

361
        os.makedirs(os.path.dirname(fpath), exist_ok=True)
1✔
362

363
        args = get_assignment_sandbox_args(
1✔
364
            ["sh", "-c", 'cat >"$1"', "sh", fpath],
365
            network_access=False,
366
            whitelist=[os.path.dirname(fpath)],
367
        )
368
        kwargs = {}
1✔
369
        if isinstance(file_text, str):
1!
370
            kwargs["text"] = True
1✔
371
            kwargs["encoding"] = "utf-8"
1✔
372

373
        try:
1✔
374
            subprocess.run(
1✔
375
                args,
376
                input=file_text,
377
                stdout=subprocess.DEVNULL,
378
                stderr=subprocess.PIPE,
379
                check=True,
380
                **kwargs,
381
            )
382
        except FileNotFoundError as e:
×
383
            logger.error("Cannot run processes: %s", e)
×
384
            raise FileNotFoundError from e
×
385

386
    def get_file(self, file_id: int) -> tuple[str, str]:
1✔
387
        self.make_assignment_dir()
×
388

389
        for i, item in enumerate(
×
390
            os.scandir(os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}"))
391
        ):
392
            if i == file_id and os.path.exists(item.path) and item.is_file():
×
393
                return item.name, item.path
×
394
        return "", ""
×
395

396
    def delete_file(self, file_id: int) -> None:
1✔
397
        """Delete a file by id"""
398
        self.make_assignment_dir()
×
399

400
        for i, item in enumerate(
×
401
            os.scandir(os.path.join(settings.MEDIA_ROOT, f"assignment-{self.id}"))
402
        ):
403
            if i == file_id and os.path.exists(item.path) and item.is_file():
×
404
                os.remove(item.path)
×
405
                return
×
406

407
    def check_rate_limit(self, student) -> None:
1✔
408
        """Check if a student is submitting too quickly"""
409
        now = timezone.localtime()
1✔
410

411
        if (
1!
412
            Submission.objects.filter(
413
                date_submitted__gte=now
414
                - datetime.timedelta(minutes=self.submission_limit_interval),
415
                student=student,
416
            ).count()
417
            > self.submission_limit_count
418
        ):
419
            with transaction.atomic():
×
420
                current_cooldown = CooldownPeriod.objects.filter(
×
421
                    assignment=self, student=student
422
                ).first()
423
                if current_cooldown:
×
424
                    current_cooldown.delete()
×
425
                CooldownPeriod.objects.create(assignment=self, student=student)
×
426

427
    @property
1✔
428
    def venv_fully_created(self):
1✔
429
        return self.venv and self.venv.fully_created
1✔
430

431
    @property
1✔
432
    def grader_log_filename(self) -> str:
1✔
433
        return f"{upload_grader_file_path(self, '').rsplit('.', 1)[0]}.log"
1✔
434

435
    def quiz_open_for_student(self, student):
1✔
436
        """Check if a quiz is open for a specific student"""
437
        is_teacher = self.course.teacher.filter(id=student.id).exists()
1✔
438
        if is_teacher or student.is_superuser:
1!
439
            return True
×
440
        return not (self.quiz_ended_for_student(student) or self.quiz_locked_for_student(student))
1✔
441

442
    def quiz_ended_for_student(self, student) -> bool:
1✔
443
        """Check if the quiz has ended for a student"""
444
        return self.log_messages.filter(student=student, content="Ended quiz").exists()
1✔
445

446
    def quiz_locked_for_student(self, student) -> bool:
1✔
447
        """Check if the quiz has been locked (e.g. due to leaving the tab)"""
448
        return self.quiz_issues_for_student(student) and self.quiz_action == "2"
1✔
449

450
    def quiz_issues_for_student(self, student) -> bool:
1✔
451
        """Check if the student has exceeded the maximum amount of issues they can have with a quiz."""
452
        return (
1✔
453
            sum(lm.severity for lm in self.log_messages.filter(student=student))
454
            >= settings.QUIZ_ISSUE_THRESHOLD
455
        )
456

457

458
class SubmissionCap(models.Model):
1✔
459
    """Submission cap information"""
460

461
    student = models.ForeignKey(
1✔
462
        settings.AUTH_USER_MODEL,
463
        on_delete=models.CASCADE,
464
        null=True,
465
        blank=True,
466
    )
467

468
    assignment = models.ForeignKey(
1✔
469
        Assignment,
470
        on_delete=models.CASCADE,
471
        related_name="submission_caps",
472
    )
473

474
    submission_cap = models.PositiveSmallIntegerField(
1✔
475
        null=True,
476
        blank=True,
477
        validators=[MinValueValidator(1)],
478
    )
479
    submission_cap_after_due = models.PositiveSmallIntegerField(null=True, blank=True)
1✔
480

481
    class Meta:
1✔
482
        constraints = [
1✔
483
            # TODO: In django 5.0+ add nulls_distinct=False
484
            models.UniqueConstraint(fields=["student", "assignment"], name="unique_type"),
485
            models.CheckConstraint(
486
                check=Q(submission_cap__isnull=False) | Q(submission_cap_after_due__isnull=False),
487
                violation_error_message="Either the submission cap before or after the due date has to be set",
488
                name="has_submission_cap",
489
            ),
490
        ]
491

492
    def __str__(self) -> str:
1✔
493
        return f"{type(self).__name__}(submission_cap={self.submission_cap})"
×
494

495

496
class CooldownPeriod(models.Model):
1✔
497
    assignment = models.ForeignKey(
1✔
498
        Assignment,
499
        on_delete=models.CASCADE,
500
        related_name="cooldown_periods",
501
    )
502
    student = models.ForeignKey(
1✔
503
        settings.AUTH_USER_MODEL,
504
        on_delete=models.CASCADE,
505
        related_name="cooldown_periods",
506
    )
507

508
    start_time = models.DateTimeField(auto_now_add=True)
1✔
509

510
    class Meta:
1✔
511
        constraints = [
1✔
512
            models.UniqueConstraint(
513
                name="unique_assignment_student",
514
                fields=["assignment", "student"],
515
            ),
516
        ]
517

518
    def __str__(self):
1✔
519
        return f"{self.student} cooldown on {self.assignment}"
×
520

521
    def __repr__(self):
522
        return f"{self.student} cooldown on {self.assignment}"
523

524
    @classmethod
1✔
525
    def exists(cls, assignment: Assignment, student) -> bool:
1✔
526
        try:
1✔
527
            obj = cls.objects.get(assignment=assignment, student=student)
1✔
528
        except cls.DoesNotExist:
1✔
529
            return False
1✔
530
        else:
531
            if obj.get_time_to_end() < datetime.timedelta():
×
532
                # Ended already
533
                return False
×
534
            else:
535
                return True
×
536

537
    def get_time_to_end(self) -> datetime.timedelta:
1✔
538
        return (
×
539
            self.start_time
540
            + datetime.timedelta(minutes=self.assignment.submission_limit_cooldown)
541
            - timezone.localtime()
542
        )
543

544

545
class Quiz(models.Model):
1✔
546
    """A Quiz Model
547

548
    .. warning::
549

550
        This model is deprecated and will be removed in the future.
551
        It is kept for backwards compatibility with existing data.
552
        All fields and methods have been migrated to the :class:`.Assignment` model
553

554
    """
555

556
    QUIZ_ACTIONS = (("0", "Log only"), ("1", "Color Change"), ("2", "Lock"))
1✔
557

558
    assignment = models.OneToOneField(
1✔
559
        Assignment,
560
        on_delete=models.CASCADE,
561
    )
562
    action = models.CharField(max_length=1, choices=QUIZ_ACTIONS)
1✔
563

564
    class Meta:
1✔
565
        verbose_name_plural = "quizzes"
1✔
566

567
    def __str__(self):
1✔
568
        return f"Quiz for {self.assignment}"
×
569

570
    def __repr__(self):
571
        return f"Quiz for {self.assignment}"
572

573
    def get_absolute_url(self):
1✔
574
        return reverse("assignments:show", args=(self.assignment.id,))
×
575

576
    def issues_for_student(self, student):
1✔
577
        return (
×
578
            sum(lm.severity for lm in self.assignment.log_messages.filter(student=student))
579
            >= settings.QUIZ_ISSUE_THRESHOLD
580
        )
581

582
    def open_for_student(self, student):
1✔
583
        is_teacher = student in self.assignment.course.teacher.all()
×
584
        if is_teacher or student.is_superuser:
×
585
            return True
×
586
        return not (self.locked_for_student(student) or self.ended_for_student(student))
×
587

588
    def locked_for_student(self, student):
1✔
589
        return self.issues_for_student(student) and self.action == "2"
×
590

591
    def ended_for_student(self, student):
1✔
592
        return self.assignment.log_messages.filter(student=student, content="Ended quiz").exists()
×
593

594

595
class QuizLogMessage(models.Model):
1✔
596
    """A log message for an :class:`Assignment` (with :attr:`~.Assignment.is_quiz` set to ``True``)"""
597

598
    assignment = models.ForeignKey(
1✔
599
        Assignment, on_delete=models.CASCADE, related_name="log_messages"
600
    )
601
    student = models.ForeignKey(
1✔
602
        get_user_model(), on_delete=models.CASCADE, related_name="log_messages"
603
    )
604

605
    date = models.DateTimeField(auto_now_add=True)
1✔
606
    content = models.CharField(max_length=100)
1✔
607
    severity = models.IntegerField()
1✔
608

609
    def __str__(self):
1✔
610
        return f"{self.content} for {self.assignment} by {self.student}"
×
611

612
    def __repr__(self):
613
        return f"{self.content} for {self.assignment} by {self.student}"
614

615
    def get_absolute_url(self):
1✔
616
        return reverse("assignments:student_submission", args=(self.assignment.id, self.student.id))
×
617

618

619
def moss_base_file_path(obj, _):  # pylint: disable=unused-argument
1✔
620
    assert obj.assignment.id is not None
×
621
    return f"assignment-{obj.assignment.id}/moss-{obj.id}/base.{obj.extension}"
×
622

623

624
class MossResult(models.Model):
1✔
625
    # from http://moss.stanford.edu/general/scripts.html
626
    LANGUAGES = (
1✔
627
        ("c", "C"),
628
        ("cc", "C++"),
629
        ("java", "Java"),
630
        ("ml", "ML"),
631
        ("pascal", "Pascal"),
632
        ("ada", "Ada"),
633
        ("lisp", "Lisp"),
634
        ("scheme", "Scheme"),
635
        ("haskell", "Haskell"),
636
        ("fortran", "Fortran"),
637
        ("ascii", "ASCII"),
638
        ("vhdl", "VHDL"),
639
        ("verilog", "Verilog"),
640
        ("perl", "Perl"),
641
        ("matlab", "Matlab"),
642
        ("python", "Python"),
643
        ("mips", "MIPS"),
644
        ("prolog", "Prolog"),
645
        ("spice", "Spice"),
646
        ("vb", "Visual Basic"),
647
        ("csharp", "C#"),
648
        ("modula2", "Modula-2"),
649
        ("a8086", "a8086 Assembly"),
650
        ("javascript", "JavaScript"),
651
        ("plsql", "PL/SQL"),
652
    )
653

654
    assignment = models.ForeignKey(
1✔
655
        Assignment,
656
        on_delete=models.CASCADE,
657
        related_name="moss_results",
658
    )
659
    period = models.ForeignKey(
1✔
660
        Period,
661
        on_delete=models.SET_NULL,
662
        null=True,
663
        blank=True,
664
        related_name="moss_results",
665
    )
666

667
    language = models.CharField(max_length=10, choices=LANGUAGES, default="python")
1✔
668
    base_file = models.FileField(upload_to=moss_base_file_path, null=True, blank=True)
1✔
669
    user_id = models.CharField(max_length=20)
1✔
670

671
    date = models.DateTimeField(auto_now_add=True)
1✔
672
    url = models.URLField(max_length=200, null=True, blank=True)
1✔
673
    status = models.CharField(max_length=1024, default="", null=False, blank=True)
1✔
674

675
    def __str__(self):
1✔
676
        return f"Moss result for {self.assignment}"
×
677

678
    def __repr__(self):
679
        return f"Moss result for {self.assignment}"
680

681
    @property
1✔
682
    def extension(self):
1✔
683
        return "java" if self.language == "java" else "py"
×
684

685
    @property
1✔
686
    def download_folder(self):
1✔
687
        return os.path.join(settings.MEDIA_ROOT, "moss-runs", f"moss-{self.id}")
×
688

689

690
def run_action(command: list[str]) -> str:
1✔
691
    """Runs a command.
692

693
    If the command cannot find a file, raises an exception.
694
    Otherwise, returns the stdout of the command.
695
    """
696
    try:
×
697
        res = subprocess.run(
×
698
            command,
699
            check=False,
700
            stdin=subprocess.DEVNULL,
701
            stdout=subprocess.PIPE,
702
            stderr=subprocess.STDOUT,
703
            encoding="utf-8",
704
            text=True,
705
        )
706
    except FileNotFoundError as e:
×
707
        logger.error("File not found: %s", e)
×
708
        raise FileNotFoundError from e
×
709
    return res.stdout
×
710

711

712
class FileAction(models.Model):
1✔
713
    """Runs a user uploaded script on files uploaded to an assignment."""
714

715
    MATCH_TYPES = (("S", "Start with"), ("E", "End with"), ("C", "Contain"))
1✔
716

717
    name = models.CharField(max_length=50)
1✔
718

719
    courses = models.ManyToManyField(Course, related_name="file_actions")
1✔
720
    command = models.CharField(max_length=1024)
1✔
721

722
    match_type = models.CharField(max_length=1, choices=MATCH_TYPES, null=True, blank=True)
1✔
723
    match_value = models.CharField(max_length=100, null=True, blank=True)
1✔
724
    case_sensitive_match = models.BooleanField(default=False)
1✔
725

726
    is_sandboxed = models.BooleanField(default=True)
1✔
727

728
    def __str__(self):
1✔
729
        return self.name
×
730

731
    def __repr__(self):
732
        return self.name
733

734
    def run(self, assignment: Assignment):
1✔
735
        """Runs the command on the input assignment"""
736
        command = self.command.split(" ")
×
737

738
        if (
×
739
            ("$FILE" in self.command or "$FILES" in self.command)
740
            and self.match_type
741
            and self.match_value
742
        ):
743
            filepaths = []
×
744

745
            for file in assignment.list_files():
×
746
                if self.case_sensitive_match:
×
747
                    filename = file[1]
×
748
                    match = self.match_value
×
749
                else:
750
                    filename = file[1].lower()
×
751
                    match = self.match_value.lower()
×
752

753
                is_match = (
×
754
                    (self.match_type == "S" and filename.startswith(match))
755
                    or (self.match_type == "E" and filename.endswith(match))
756
                    or (self.match_type == "C" and match in filename)
757
                )
758
                if is_match:
×
759
                    filepaths.append(f"{file[2]}")
×
760

761
            if "$FILES" in command:
×
762
                new_command = []
×
763
                for command_part in command:
×
764
                    if command_part == "$FILES":
×
765
                        new_command.extend(filepaths)
×
766
                    else:
767
                        new_command.append(command_part)
×
768
                command = new_command
×
769

770
            # Special case for multi-command actions
771
            if "$FILE" in command:
×
772
                output = self.name
×
773
                for filepath in filepaths:
×
774
                    new_command = [filepath if part == "$FILE" else part for part in command]
×
775
                    output += "\n-----\n" + " ".join(new_command) + "\n-----\n"
×
776
                    if self.is_sandboxed:
×
777
                        new_command = get_action_sandbox_args(new_command, network_access=False)
×
778
                    output += run_action(new_command)
×
779

780
                assignment.last_action_output = output
×
781
                assignment.save()
×
782
                return
×
783

784
        if self.is_sandboxed:
×
785
            command = get_action_sandbox_args(command, network_access=False)
×
786

787
        output = self.name + "\n" + " ".join(command) + "\n-----\n"
×
788
        output += run_action(command)
×
789

790
        assignment.last_action_output = output
×
791
        assignment.save()
×
792

793

794
class Language(models.Model):
1✔
795
    """Which version of a language is used for an assignment."""
796

797
    LANGUAGES = (
1✔
798
        ("P", "Python 3"),
799
        ("J", "Java"),
800
    )
801

802
    name = models.CharField(max_length=50, help_text="The name of the language")
1✔
803
    info = models.JSONField()
1✔
804
    language = models.CharField(max_length=1, choices=LANGUAGES)
1✔
805
    is_deprecated = models.BooleanField(default=False)
1✔
806

807
    class Meta:
1✔
808
        ordering = ["-language", "-info__version"]
1✔
809

810
    def __str__(self) -> str:
1✔
NEW
811
        return self.name
×
812

813
    def __repr__(self) -> str:
814
        return f"<{type(self).__name__}: {self.name} ({self.language}) >"
815

816
    def clean(self) -> None:
1✔
NEW
817
        super().clean()
×
NEW
818
        if not isinstance(self.info, dict):
×
NEW
819
            raise ValidationError("Info must be a dictionary")
×
NEW
820
        if self.language == "P" and (
×
821
            "python3" not in self.info or not isinstance(self.info["version"], float)
822
        ):
NEW
823
            raise ValidationError(
×
824
                "Python 3 language must have a 'python3' key and a (float) version key!"
825
            )
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