• 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

57.67
/tin/apps/assignments/views.py
1
from __future__ import annotations
1✔
2

3
import csv
1✔
4
import datetime
1✔
5
import logging
1✔
6
import os
1✔
7
import subprocess
1✔
8
import uuid
1✔
9
import zipfile
1✔
10
from io import BytesIO
1✔
11
from pathlib import Path
1✔
12

13
import celery
1✔
14
from django import http
1✔
15
from django.conf import settings
1✔
16
from django.contrib.auth import get_user_model
1✔
17
from django.shortcuts import get_object_or_404, redirect, render
1✔
18
from django.urls import reverse
1✔
19
from django.utils.text import slugify
1✔
20
from django.utils.timezone import now
1✔
21
from django.views.decorators.http import require_POST
1✔
22

23
from ... import sandboxing
1✔
24
from ..auth.decorators import login_required, teacher_or_superuser_required
1✔
25
from ..courses.models import Course, Period
1✔
26
from ..submissions.models import PublishedSubmission, Submission
1✔
27
from ..submissions.tasks import run_submission
1✔
28
from ..users.models import User
1✔
29
from .forms import (
1✔
30
    AssignmentForm,
31
    FileSubmissionForm,
32
    FileUploadForm,
33
    FolderForm,
34
    GraderScriptUploadForm,
35
    ImageForm,
36
    MossForm,
37
    SubmissionCapForm,
38
    TextSubmissionForm,
39
)
40
from .models import Assignment, CooldownPeriod, QuizLogMessage, SubmissionCap
1✔
41
from .tasks import run_moss
1✔
42

43
logger = logging.getLogger(__name__)
1✔
44

45

46
@login_required
1✔
47
def show_view(request, assignment_id):
1✔
48
    """Shows an overview of the :class:`.Assignment`
49

50
    Args:
51
        request : The request
52
        assignment_id : the :class:`.Assignment` id
53
    """
54
    assignment: Assignment = get_object_or_404(
1✔
55
        Assignment.objects.filter_visible(request.user), id=assignment_id
56
    )
57
    course = assignment.course
1✔
58
    quiz_accessible = assignment.is_quiz and assignment.quiz_open_for_student(request.user)
1✔
59

60
    if course.is_only_student_in_course(request.user):
1✔
61
        submissions = Submission.objects.filter(student=request.user, assignment=assignment)
1✔
62
        latest_submission = submissions.latest() if submissions else None
1✔
63
        publishes = PublishedSubmission.objects.filter(student=request.user, assignment=assignment)
1✔
64
        graded_submission = publishes.latest().submission if publishes else latest_submission
1✔
65

66
        submission_limit = assignment.find_submission_cap(request.user)
1✔
67
        submissions_left = submission_limit - len(submissions)
1✔
68
        submissions_left = max(submissions_left, 0)
1✔
69

70
        if submissions_left == float("inf"):
1!
71
            submissions_left = None
1✔
72
        if submission_limit == float("inf"):
1!
73
            submission_limit = None
1✔
74

75
        return render(
1✔
76
            request,
77
            "assignments/show.html",
78
            {
79
                "course": assignment.course,
80
                "folder": assignment.folder,
81
                "assignment": assignment,
82
                "submissions": submissions.order_by("-date_submitted"),
83
                "latest_submission": latest_submission,
84
                "graded_submission": graded_submission,
85
                "is_student": course.is_student_in_course(request.user),
86
                "is_teacher": request.user in course.teacher.all(),
87
                "quiz_accessible": quiz_accessible,
88
                "within_submission_limit": assignment.within_submission_limit(request.user),
89
                "submissions_used": len(submissions),
90
                "submissions_left": submissions_left,
91
                "submission_limit": submission_limit,
92
            },
93
        )
94
    else:
95
        students_and_submissions = []
1✔
96
        new_since_last_login = None
1✔
97
        new_in_last_24 = None
1✔
98
        teacher_last_login = request.user.last_login
1✔
99
        time_24_hours_ago = now() - datetime.timedelta(days=1)
1✔
100

101
        query = request.GET.get("query", "")
1✔
102

103
        filter = request.GET.get("filter", "")
1✔
104

105
        period = request.GET.get("period", "")
1✔
106
        period_set = course.period_set.order_by("teacher", "name")
1✔
107

108
        if query:
1!
109
            active_period = "query"
×
110
            student_list = course.students.filter(full_name__icontains=query).order_by(
×
111
                "last_name", "first_name"
112
            )
113
        elif filter:
1!
114
            active_period = "filter"
×
115
            if filter == "no_submissions":
×
116
                student_list = (
×
117
                    course.students.exclude(submissions__assignment=assignment)
118
                    .order_by("last_name", "first_name")
119
                    .distinct()
120
                )
121
            elif filter == "with_submissions":
×
122
                student_list = (
×
123
                    course.students.filter(submissions__assignment=assignment)
124
                    .order_by("last_name", "first_name")
125
                    .distinct()
126
                )
127
            else:
128
                student_list = course.students.all().order_by("last_name", "first_name")
×
129
        elif period == "all":
1!
130
            active_period = "all"
×
131
            student_list = course.students.all().order_by("last_name", "first_name")
×
132
        elif period == "none":
1!
133
            active_period = "none"
×
134
            student_list = []
×
135
        elif period == "teachers":
1!
136
            active_period = "teachers"
×
137
            student_list = course.teacher.all().order_by("last_name", "first_name")
×
138
        elif course.period_set.exists():
1!
139
            if period == "":
×
140
                if request.user in course.teacher.all():
×
141
                    try:
×
142
                        period = (
×
143
                            course.period_set.filter(teacher=request.user).order_by("name")[0].id
144
                        )
145
                    except IndexError:
×
146
                        period = "none"
×
147
                else:
148
                    period = "none"
×
149

150
            if period == "none":
×
151
                active_period = "none"
×
152
                student_list = []
×
153
            else:
154
                active_period = get_object_or_404(
×
155
                    Period.objects.filter(course=course), id=int(period)
156
                )
157
                student_list = active_period.students.all().order_by("last_name", "first_name")
×
158
        else:
159
            active_period = "none"
1✔
160
            student_list = []
1✔
161

162
        for student in student_list:
1!
163
            period = student.periods.filter(course=assignment.course)
×
164
            submissions = Submission.objects.filter(student=student, assignment=assignment)
×
165
            publishes = PublishedSubmission.objects.filter(student=student, assignment=assignment)
×
166
            latest_submission = submissions.latest() if submissions else None
×
167
            graded_submission = publishes.latest().submission if publishes else latest_submission
×
168

169
            if not assignment.is_quiz:
×
170
                if latest_submission:
×
171
                    new_since_last_login = latest_submission.date_submitted > teacher_last_login
×
172
                    new_in_last_24 = latest_submission.date_submitted > time_24_hours_ago
×
173
                students_and_submissions.append(
×
174
                    (
175
                        student,
176
                        period,
177
                        latest_submission,
178
                        graded_submission,
179
                        new_since_last_login,
180
                        new_in_last_24,
181
                    )
182
                )
183
            else:
184
                students_and_submissions.append(
×
185
                    (
186
                        student,
187
                        period,
188
                        latest_submission,
189
                        graded_submission,
190
                        assignment.quiz_ended_for_student(student),
191
                        assignment.quiz_locked_for_student(student),
192
                    )
193
                )
194

195
        context = {
1✔
196
            "course": course,
197
            "folder": assignment.folder,
198
            "assignment": assignment,
199
            "students_and_submissions": students_and_submissions,
200
            "log_file_exists": (
201
                os.path.exists(os.path.join(settings.MEDIA_ROOT, assignment.grader_log_filename))
202
            ),
203
            "is_student": course.is_student_in_course(request.user),
204
            "is_teacher": request.user in course.teacher.all(),
205
            "query": query,
206
            "filter": filter,
207
            "period_set": period_set,
208
            "active_period": active_period,
209
            "quiz_accessible": quiz_accessible,
210
        }
211

212
        submissions = Submission.objects.filter(student=request.user, assignment=assignment)
1✔
213
        publishes = PublishedSubmission.objects.filter(student=request.user, assignment=assignment)
1✔
214
        latest_submission = submissions.latest() if submissions else None
1✔
215
        graded_submission = publishes.latest().submission if publishes else latest_submission
1✔
216

217
        submission_limit = assignment.find_submission_cap(request.user)
1✔
218
        num_submissions = len(submissions)
1✔
219
        submissions_left = submission_limit - num_submissions
1✔
220
        submissions_left = max(submissions_left, 0)
1✔
221

222
        # render properly after submission cap is lowered (such as when the due date is passed)
223
        if submissions_left == float("inf"):
1!
224
            submissions_left = None
1✔
225
        if submission_limit == float("inf"):
1!
226
            submission_limit = None
1✔
227

228
        context.update(
1✔
229
            {
230
                "submissions": submissions.order_by("-date_submitted"),
231
                "latest_submission": latest_submission,
232
                "graded_submission": graded_submission,
233
                "within_submission_limit": assignment.within_submission_limit(request.user),
234
                "submissions_used": num_submissions,
235
                "submissions_left": submissions_left,
236
                "submission_limit": submission_limit,
237
            }
238
        )
239

240
        return render(request, "assignments/show.html", context)
1✔
241

242

243
@teacher_or_superuser_required
1✔
244
def create_view(request, course_id):
1✔
245
    """Creates an assignment
246

247
    Args:
248
        request: The request
249
        course_id: The id of the :class:`.Course`
250
    """
251
    course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
1✔
252

253
    if request.method == "POST":
1!
254
        assignment_form = AssignmentForm(course, request.POST)
1✔
255
        if assignment_form.is_valid():
1!
256
            assignment_form.instance.course = course
1✔
257
            assignment = assignment_form.save()
1✔
258

259
            assignment.make_assignment_dir()
1✔
260

261
            return redirect("assignments:show", assignment.id)
1✔
262
    else:
263
        assignment_form = AssignmentForm(course)
×
264
    return render(
×
265
        request,
266
        "assignments/edit_create.html",
267
        {
268
            "assignment_form": assignment_form,
269
            "course": course,
270
            "nav_item": "Create assignment",
271
            "action": "add",
272
        },
273
    )
274

275

276
@teacher_or_superuser_required
1✔
277
def edit_view(request, assignment_id):
1✔
278
    """Edits an assignment
279

280
    Args:
281
        request: The request
282
        assignment_id: The id of the :class:`.Assignment`
283
    """
284
    assignment: Assignment = get_object_or_404(
×
285
        Assignment.objects.filter_editable(request.user), id=assignment_id
286
    )
287

288
    course = assignment.course
×
NEW
289
    initial = {"language_details": assignment.language_details}
×
290

NEW
291
    assignment_form = AssignmentForm(course, instance=assignment, initial=initial)
×
292
    if request.method == "POST":
×
293
        assignment_form = AssignmentForm(course, data=request.POST, instance=assignment)
×
294
        if assignment_form.is_valid():
×
295
            assignment_form.save()
×
296
            return redirect("assignments:show", assignment.id)
×
297

298
    return render(
×
299
        request,
300
        "assignments/edit_create.html",
301
        {
302
            "assignment_form": assignment_form,
303
            "course": assignment.course,
304
            "folder": assignment.folder,
305
            "assignment": assignment,
306
            "nav_item": "Edit",
307
            "action": "edit",
308
        },
309
    )
310

311

312
@teacher_or_superuser_required
1✔
313
def delete_view(request, assignment_id):
1✔
314
    """Deletes an assignment
315

316
    Args:
317
        request: The request
318
        assignment_id: The primary key of the :class:`.Assignment`
319
    """
320
    assignment = get_object_or_404(
1✔
321
        Assignment.objects.filter_editable(request.user), id=assignment_id
322
    )
323
    course = assignment.course
1✔
324
    folder = assignment.folder
1✔
325
    assignment.delete()
1✔
326
    if folder:
1!
327
        return redirect(reverse("assignments:show_folder", args=(course.id, folder.id)))
×
328
    return redirect(reverse("courses:show", args=(course.id,)))
1✔
329

330

331
@teacher_or_superuser_required
1✔
332
def manage_grader_view(request, assignment_id):
1✔
333
    """Uploads a grader for an assignment
334

335
    Args:
336
        request: The request
337
        assignment_id: The primary key of the :class:`.Assignment`
338
    """
339
    assignment = get_object_or_404(
1✔
340
        Assignment.objects.filter_editable(request.user), id=assignment_id
341
    )
342

343
    grader_form = GraderScriptUploadForm()
1✔
344

345
    grader_file_errors = ""
1✔
346

347
    if request.method == "POST":
1!
348
        if request.FILES.get("grader_file"):
1!
349
            if request.FILES["grader_file"].size <= settings.SUBMISSION_SIZE_LIMIT:
1✔
350
                grader_form = GraderScriptUploadForm(
1✔
351
                    request.POST,
352
                    request.FILES,
353
                )
354
                if grader_form.is_valid():
1!
355
                    try:
1✔
356
                        grader_text = request.FILES["grader_file"].read().decode()
1✔
357
                    except UnicodeDecodeError:
×
358
                        grader_file_errors = "Please don't upload binary files."
×
359
                    else:
360
                        assignment.save_grader_file(grader_text)
1✔
361

362
                        return redirect("assignments:show", assignment.id)
1✔
363
                else:
364
                    grader_file_errors = grader_form.errors
×
365
            else:
366
                grader_file_errors = "That file's too large. Are you sure it's a Python program?"
1✔
367
        else:
368
            grader_file_errors = "Please select a file."
×
369

370
    grader_file = assignment.grader_file
1✔
371
    if grader_file:
1!
372
        with grader_file.open(mode="r") as f_obj:
×
373
            grader_text = f_obj.read()
×
374
    else:
375
        grader_text = ""
1✔
376

377
    return render(
1✔
378
        request,
379
        "assignments/grader.html",
380
        {
381
            "grader_form": grader_form,
382
            "grader_file_errors": grader_file_errors,
383
            "course": assignment.course,
384
            "folder": assignment.folder,
385
            "assignment": assignment,
386
            "grader_text": grader_text,
387
            "nav_item": "Manage grader" if grader_file else "Upload grader",
388
        },
389
    )
390

391

392
@teacher_or_superuser_required
1✔
393
def download_grader_view(request, assignment_id):
1✔
394
    """Downloads the grader for an assignment
395

396
    Args:
397
        request: The request
398
        assignment_id: The primary key of the :class:`.Assignment`
399
    """
400
    assignment = get_object_or_404(
1✔
401
        Assignment.objects.filter_editable(request.user), id=assignment_id
402
    )
403

404
    grader_file = assignment.grader_file
1✔
405
    if not grader_file:
1✔
406
        raise http.Http404
1✔
407

408
    with grader_file.open() as f_obj:
1✔
409
        response = http.HttpResponse(f_obj.read(), content_type="text/plain")
1✔
410
    filename = f"{assignment.name.replace(' ', '_')}_{grader_file.name.split('/')[-1]}"
1✔
411
    response["Content-Disposition"] = f'attachment; filename="{filename}"'
1✔
412

413
    return response
1✔
414

415

416
@teacher_or_superuser_required
1✔
417
def manage_files_view(request, assignment_id):
1✔
418
    """List current assignment files and allow uploading new ones
419

420
    Args:
421
        request: The request
422
        assignment_id: The primary key of the :class:`.Assignment`
423
    """
424
    assignment = get_object_or_404(
1✔
425
        Assignment.objects.filter_editable(request.user), id=assignment_id
426
    )
427

428
    form = FileUploadForm()
1✔
429

430
    file_errors = ""
1✔
431

432
    if request.method == "POST":
1!
433
        if request.FILES.get("upload_file"):
1!
434
            if request.FILES["upload_file"].size <= settings.SUBMISSION_SIZE_LIMIT:
1✔
435
                form = FileUploadForm(
1✔
436
                    request.POST,
437
                    request.FILES,
438
                )
439
                if form.is_valid():
1!
440
                    try:
1✔
441
                        text = request.FILES["upload_file"].read().decode()
1✔
442
                    except UnicodeDecodeError:
×
443
                        file_errors = "Please don't upload binary files."
×
444
                    else:
445
                        assignment.save_file(text, request.FILES["upload_file"].name)
1✔
446

447
                        return redirect("assignments:manage_files", assignment.id)
1✔
448
                else:
449
                    file_errors = form.errors
×
450
            else:
451
                file_errors = "That file's too large."
1✔
452
        else:
453
            file_errors = "Please select a file."
×
454

455
    files = assignment.list_files()
1✔
456

457
    actions = assignment.course.file_actions.all()
1✔
458

459
    return render(
1✔
460
        request,
461
        "assignments/manage_files.html",
462
        {
463
            "files": files,
464
            "actions": actions,
465
            "form": form,
466
            "file_errors": file_errors,
467
            "course": assignment.course,
468
            "folder": assignment.folder,
469
            "assignment": assignment,
470
            "nav_item": "Manage files",
471
        },
472
    )
473

474

475
@teacher_or_superuser_required
1✔
476
def download_file_view(request, assignment_id, file_id):
1✔
477
    """Download a file
478

479
    Args:
480
        request: The request
481
        assignment_id: The primary key of the :class:`.Assignment`
482
        file_id: The id of the file
483
    """
484
    assignment = get_object_or_404(
×
485
        Assignment.objects.filter_editable(request.user), id=assignment_id
486
    )
487

488
    file_name, file_path = assignment.get_file(file_id)
×
489

490
    with open(file_path, "rb") as f_obj:
×
491
        response = http.HttpResponse(f_obj.read(), content_type="text/plain")
×
492
    response["Content-Disposition"] = f'attachment; filename="{file_name}"'
×
493

494
    return response
×
495

496

497
@teacher_or_superuser_required
1✔
498
def delete_file_view(request, assignment_id, file_id):
1✔
499
    """Delete a file
500

501
    Args:
502
        request: The request
503
        assignment_id: The primary key of the :class:`.Assignment`
504
        file_id: The id of the file to delete
505
    """
506
    assignment = get_object_or_404(
×
507
        Assignment.objects.filter_editable(request.user), id=assignment_id
508
    )
509

510
    assignment.delete_file(file_id)
×
511

512
    return redirect("assignments:manage_files", assignment.id)
×
513

514

515
@teacher_or_superuser_required
1✔
516
def file_action_view(request, assignment_id, action_id):
1✔
517
    """Run file actions on an assignment's files
518

519
    Args:
520
        request: The request
521
        assignment_id: The primary key of the :class:`.Assignment`
522
        action_id: The primary key of the :class:`.FileAction`
523
    """
524
    assignment = get_object_or_404(
×
525
        Assignment.objects.filter_editable(request.user), id=assignment_id
526
    )
527
    action = get_object_or_404(assignment.course.file_actions.all(), id=action_id)
×
528

529
    action.run(assignment)
×
530

531
    return redirect("assignments:manage_files", assignment.id)
×
532

533

534
@teacher_or_superuser_required
1✔
535
def student_submissions_view(request, assignment_id, student_id):
1✔
536
    """See the submissions of a student
537

538
    Args:
539
        request: The request
540
        assignment_id: The primary key of the :class:`.Assignment`
541
        student_id: The primary key of the student model
542
    """
543
    assignment: Assignment = get_object_or_404(
1✔
544
        Assignment.objects.filter_editable(request.user), id=assignment_id
545
    )
546
    student = get_object_or_404(User, id=student_id)
1✔
547

548
    cap = assignment.submission_caps.filter(student=student).first()
1✔
549
    form = SubmissionCapForm(instance=cap)
1✔
550
    # the following code is a bit of a hack so that POST-ing an empty form deletes the submission cap.
551
    if request.method == "POST":
1!
552
        delete_cap = (
1✔
553
            request.POST.get("submission_cap", object()) == ""
554
            and request.POST.get("submission_cap_after_due", object()) == ""
555
            and cap is not None
556
        )
557
        if delete_cap:
1!
558
            SubmissionCap.objects.filter(id=cap.id).delete()
×
559
            # ensure the initial cap values don't show up in the form
560
            form = SubmissionCapForm()
×
561

562
        # for whatever reason instantiating the form autoadds errors even if we delete
563
        if (
1!
564
            not delete_cap
565
            and (form := SubmissionCapForm(data=request.POST, instance=cap)).is_valid()
566
        ):
567
            form.instance.assignment = assignment
1✔
568
            form.instance.student = student
1✔
569
            form.save()
1✔
570

571
    submissions = Submission.objects.filter(student=student, assignment=assignment)
1✔
572
    publishes = PublishedSubmission.objects.filter(student=student, assignment=assignment)
1✔
573
    latest_submission = submissions.latest() if submissions else None
1✔
574
    published_submission = publishes.latest().submission if publishes else latest_submission
1✔
575

576
    log_messages = (
1✔
577
        assignment.log_messages.filter(student=student).order_by("date")
578
        if assignment.is_quiz
579
        else None
580
    )
581

582
    submission_cap = assignment.find_submission_cap(student)
1✔
583
    if submission_cap == float("inf"):
1!
584
        submission_cap = None
×
585

586
    return render(
1✔
587
        request,
588
        "assignments/student_submissions.html",
589
        {
590
            "course": assignment.course,
591
            "folder": assignment.folder,
592
            "assignment": assignment,
593
            "student": student,
594
            "submissions": submissions.order_by("-date_submitted"),
595
            "latest_submission": latest_submission,
596
            "published_submission": published_submission,
597
            "log_messages": log_messages,
598
            "form": form,
599
            "submission_cap": submission_cap,
600
        },
601
    )
602

603

604
@login_required
1✔
605
def submit_view(request, assignment_id):
1✔
606
    """Submit an assignment
607

608
    Args:
609
        request: The request
610
        assignment_id: The primary key of the :class:`.Assignment`
611
    """
612
    assignment = get_object_or_404(
1✔
613
        Assignment.objects.filter_submittable(request.user),
614
        id=assignment_id,
615
    )
616

617
    if assignment.is_quiz:
1✔
618
        raise http.Http404
1✔
619

620
    student = request.user
1✔
621
    if not assignment.within_submission_limit(student):
1✔
622
        return http.HttpResponseForbidden("Submission limit exceeded")
1✔
623

624
    submissions = Submission.objects.filter(student=student, assignment=assignment)
1✔
625
    latest_submission = submissions.latest() if submissions else None
1✔
626
    latest_submission_text = latest_submission.file_text if latest_submission else None
1✔
627

628
    file_form = FileSubmissionForm()
1✔
629
    text_form = TextSubmissionForm(initial={"text": latest_submission_text})
1✔
630

631
    file_errors = ""
1✔
632
    text_errors = ""
1✔
633

634
    if request.method == "POST":
1✔
635
        if assignment.grader_file is None:
1!
636
            return redirect("assignments:show", assignment.id)
×
637

638
        if (
1!
639
            Submission.objects.filter(student=request.user, complete=False).count()
640
            >= settings.CONCURRENT_USER_SUBMISSION_LIMIT
641
        ):
642
            if request.FILES.get("file"):
×
643
                file_form = FileSubmissionForm(request.POST, request.FILES)
×
644
                file_errors = (
×
645
                    "You may only have a maximum of {} submission{} running at the same "
646
                    "time".format(
647
                        settings.CONCURRENT_USER_SUBMISSION_LIMIT,
648
                        "" if settings.CONCURRENT_USER_SUBMISSION_LIMIT == 1 else "s",
649
                    )
650
                )
651
            else:
652
                text_form = TextSubmissionForm(request.POST)
×
653
                text_errors = (
×
654
                    "You may only have a maximum of {} submission{} running at the same "
655
                    "time".format(
656
                        settings.CONCURRENT_USER_SUBMISSION_LIMIT,
657
                        "" if settings.CONCURRENT_USER_SUBMISSION_LIMIT == 1 else "s",
658
                    )
659
                )
660
        elif CooldownPeriod.exists(assignment=assignment, student=student):
1!
661
            cooldown_period = CooldownPeriod.objects.get(assignment=assignment, student=student)
×
662

663
            end_delta = cooldown_period.get_time_to_end()
×
664
            # Throw out the microseconds
665
            end_delta = datetime.timedelta(days=end_delta.days, seconds=end_delta.seconds)
×
666

667
            if request.FILES.get("file"):
×
668
                file_form = FileSubmissionForm(request.POST, request.FILES)
×
669
                file_errors = (
×
670
                    "You have made too many submissions too quickly. You will be able to "
671
                    f"re-submit in {end_delta}."
672
                )
673
            else:
674
                text_form = TextSubmissionForm(request.POST)
×
675
                text_errors = (
×
676
                    "You have made too many submissions too quickly. You will be able to "
677
                    f"re-submit in {end_delta}."
678
                )
679
        elif request.FILES.get("file"):
1✔
680
            if request.FILES["file"].size <= settings.SUBMISSION_SIZE_LIMIT:
1!
681
                file_form = FileSubmissionForm(request.POST, request.FILES)
1✔
682
                if file_form.is_valid():
1!
683
                    try:
1✔
684
                        submission_text = request.FILES["file"].read().decode()
1✔
685
                    except UnicodeDecodeError:
×
686
                        file_errors = "Please don't upload binary files."
×
687
                    else:
688
                        submission = Submission()
1✔
689
                        submission.assignment = assignment
1✔
690
                        submission.student = student
1✔
691
                        submission.save_file(submission_text)
1✔
692
                        submission.save()
1✔
693

694
                        assignment.check_rate_limit(student)
1✔
695

696
                        submission.create_backup_copy(submission_text)
1✔
697

698
                        run_submission.delay(submission.id)
1✔
699
                        return redirect("assignments:show", assignment.id)
1✔
700
            else:
701
                file_errors = "That file's too large. Are you sure it's a Python program?"
×
702
        else:
703
            text_form = TextSubmissionForm(request.POST)
1✔
704
            if text_form.is_valid():
1!
705
                submission_text = text_form.cleaned_data["text"]
1✔
706
                if len(submission_text) <= settings.SUBMISSION_SIZE_LIMIT:
1!
707
                    submission = text_form.save(commit=False)
1✔
708
                    submission.assignment = assignment
1✔
709
                    submission.student = student
1✔
710
                    submission.save_file(submission_text)
1✔
711
                    submission.save()
1✔
712

713
                    assignment.check_rate_limit(student)
1✔
714

715
                    submission.create_backup_copy(submission_text)
1✔
716

717
                    run_submission.delay(submission.id)
1✔
718
                    return redirect("assignments:show", assignment.id)
1✔
719
                else:
720
                    text_errors = "Submission too large"
×
721

722
    return render(
1✔
723
        request,
724
        "assignments/submit.html",
725
        {
726
            "file_form": file_form,
727
            "text_form": text_form,
728
            "file_errors": file_errors,
729
            "text_errors": text_errors,
730
            "course": assignment.course,
731
            "folder": assignment.folder,
732
            "assignment": assignment,
733
            "nav_item": "Submit",
734
        },
735
    )
736

737

738
def rerun_view(request, assignment_id):
1✔
739
    """Rerun select submissions
740

741
    Args:
742
        request: The request
743
        assignment_id: The primary key of the :class:`.Assignment`
744
    """
745
    assignment = get_object_or_404(
×
746
        Assignment.objects.filter_editable(request.user), id=assignment_id
747
    )
748
    course = assignment.course
×
749
    period = request.GET.get("period", "")
×
750

751
    if period == "all":
×
752
        students = course.students.all()
×
753
    elif course.period_set.exists():
×
754
        students = get_object_or_404(
×
755
            Period.objects.filter(course=course), id=int(period)
756
        ).students.all()
757
    else:
758
        raise http.Http404
×
759

760
    submission_reruns = []
×
761

762
    for student in students:
×
763
        submissions = Submission.objects.filter(student=student, assignment=assignment)
×
764
        latest_submission = submissions.latest() if submissions else None
×
765
        publishes = PublishedSubmission.objects.filter(student=request.user, assignment=assignment)
×
766
        published_submission = publishes.latest().submission if publishes else latest_submission
×
767
        if published_submission:
×
768
            submission_reruns.append(published_submission.rerun())
×
769

770
    celery.group(submission_reruns).delay()
×
771

772
    return redirect("assignments:show", assignment.id)
×
773

774

775
@login_required
1✔
776
def quiz_view(request, assignment_id):
1✔
777
    """The view for taking a Quiz
778

779
    Args:
780
        request: The request
781
        assignment_id: The primary key of the :class:`.Assignment` model
782
    """
783
    assignment: Assignment = get_object_or_404(
1✔
784
        Assignment.objects.filter_visible(request.user).filter(course__archived=False),
785
        id=assignment_id,
786
    )
787

788
    if not assignment.is_quiz or not assignment.quiz_open_for_student(request.user):
1✔
789
        raise http.Http404
1✔
790

791
    student = request.user
1✔
792

793
    submissions = Submission.objects.filter(student=student, assignment=assignment)
1✔
794
    latest_submission = submissions.latest() if submissions else None
1✔
795
    latest_submission_text = latest_submission.file_text if latest_submission else ""
1✔
796

797
    text_form = TextSubmissionForm(initial={"text": latest_submission_text})
1✔
798
    text_errors = ""
1✔
799

800
    if request.method == "POST":
1!
801
        if assignment.grader_file is None:
1!
802
            return redirect("assignments:show", assignment.id)
×
803

804
        if (
1!
805
            Submission.objects.filter(student=request.user, complete=False).count()
806
            >= settings.CONCURRENT_USER_SUBMISSION_LIMIT
807
        ):
808
            text_form = TextSubmissionForm(request.POST)
×
809
            text_errors = (
×
810
                "You may only have a maximum of {} submission{} running at the same time".format(
811
                    settings.CONCURRENT_USER_SUBMISSION_LIMIT,
812
                    "" if settings.CONCURRENT_USER_SUBMISSION_LIMIT == 1 else "s",
813
                )
814
            )
815
        elif CooldownPeriod.exists(assignment=assignment, student=student):
1!
816
            cooldown_period = CooldownPeriod.objects.get(assignment=assignment, student=student)
×
817

818
            end_delta = cooldown_period.get_time_to_end()
×
819
            # Throw out the microseconds
820
            end_delta = datetime.timedelta(days=end_delta.days, seconds=end_delta.seconds)
×
821

822
            text_form = TextSubmissionForm(request.POST)
×
823
            text_errors = (
×
824
                "You have made too many submissions too quickly. You will be able to re-submit"
825
                f"in {end_delta}."
826
            )
827
        else:
828
            text_form = TextSubmissionForm(request.POST)
1✔
829
            if text_form.is_valid():
1!
830
                submission_text = text_form.cleaned_data["text"]
1✔
831
                if len(submission_text) <= settings.SUBMISSION_SIZE_LIMIT:
1!
832
                    submission = text_form.save(commit=False)
1✔
833
                    submission.assignment = assignment
1✔
834
                    submission.student = student
1✔
835
                    submission.save_file(submission_text)
1✔
836
                    submission.save()
1✔
837

838
                    assignment.check_rate_limit(student)
1✔
839

840
                    submission.create_backup_copy(submission_text)
1✔
841

842
                    run_submission.delay(submission.id)
1✔
843
                    return redirect("assignments:quiz", assignment.id)
1✔
844
                else:
845
                    text_errors = "Submission too large"
×
846

847
    quiz_color = assignment.quiz_issues_for_student(request.user) and assignment.quiz_action == "1"
×
848

849
    return render(
×
850
        request,
851
        "assignments/quiz.html",
852
        {
853
            "nav_item": "Take Quiz",
854
            "course": assignment.course,
855
            "folder": assignment.folder,
856
            "assignment": assignment,
857
            "latest_submission": latest_submission,
858
            "text_form": text_form,
859
            "text_errors": text_errors,
860
            "quiz_color": quiz_color,
861
        },
862
    )
863

864

865
@login_required
1✔
866
def quiz_report_view(request, assignment_id):
1✔
867
    """Allows client-side JavaScript to report quiz log messages
868

869
    Args:
870
        request: The request
871
        assignment_id: The primary key of the :class:`.Assignment` model
872
    """
873
    assignment = get_object_or_404(
1✔
874
        Assignment.objects.filter_visible(request.user), id=assignment_id
875
    )
876

877
    content = request.GET.get("content", "")
1✔
878
    severity = int(request.GET.get("severity", 0))
1✔
879

880
    action = "no action"
1✔
881

882
    if not assignment.quiz_ended_for_student(request.user):
1✔
883
        QuizLogMessage.objects.create(
1✔
884
            assignment=assignment, student=request.user, content=content, severity=severity
885
        )
886

887
        if severity >= settings.QUIZ_ISSUE_THRESHOLD:
1✔
888
            if assignment.quiz_action == "1":
1✔
889
                action = "color"
1✔
890
            elif assignment.quiz_action == "2":
1!
891
                action = "lock"
1✔
892

893
    return http.JsonResponse({"action": action})
1✔
894

895

896
@login_required
1✔
897
def quiz_end_view(request, assignment_id):
1✔
898
    """The view for ending a quiz
899

900
    Args:
901
        request: The request
902
        assignment_id: The primary key of the :class:`.Assignment` model
903
    """
904
    assignment = get_object_or_404(
1✔
905
        Assignment.objects.filter_visible(request.user), id=assignment_id
906
    )
907

908
    QuizLogMessage.objects.create(
1✔
909
        assignment=assignment, student=request.user, content="Ended quiz", severity=0
910
    )
911

912
    return redirect("assignments:show", assignment.id)
1✔
913

914

915
@teacher_or_superuser_required
1✔
916
def quiz_clear_view(request, assignment_id, user_id):
1✔
917
    """Clear the log messages of a user.
918

919
    This effectively also restarts the quiz.
920

921
    Args:
922
        request: The request
923
        assignment_id: The primary key of the :class:`.Assignment` model
924
        user_id: The primary key of the user
925
    """
926
    assignment = get_object_or_404(
1✔
927
        Assignment.objects.filter_editable(request.user), id=assignment_id
928
    )
929
    user = get_object_or_404(get_user_model(), id=user_id)
1✔
930

931
    assignment.log_messages.filter(student=user).delete()
1✔
932

933
    return redirect("assignments:student_submission", assignment.id, user.id)
1✔
934

935

936
@teacher_or_superuser_required
1✔
937
def scores_csv_view(request, assignment_id):
1✔
938
    """Get a ``.csv`` of the scores
939

940
    Args:
941
        request: The request
942
        assignment_id: The primary key of the :class:`.Assignment` model
943
    """
944
    assignment = get_object_or_404(
1✔
945
        Assignment.objects.filter_editable(request.user), id=assignment_id
946
    )
947
    course = assignment.course
1✔
948
    period = request.GET.get("period", "")
1✔
949

950
    if period == "all":
1!
951
        students = course.students.all()
1✔
952
        name = f"assignment_{assignment.id}_all_scores.csv"
1✔
953
    elif course.period_set.exists():
×
954
        period_obj = get_object_or_404(Period.objects.filter(course=course), id=int(period))
×
955
        students = period_obj.students.all()
×
956
        name = "assignment_{}_period_{}_scores.csv".format(
×
957
            assignment.id, period_obj.name.replace(" ", "_")
958
        )
959
    else:
960
        raise http.Http404
×
961

962
    response = http.HttpResponse(content_type="text/csv")
1✔
963
    response["Content-Disposition"] = f"attachment; filename={name}"
1✔
964

965
    writer = csv.writer(response)
1✔
966
    writer.writerow(["Name", "Username", "Period", "Raw Score", "Final Score", "Formatted Grade"])
1✔
967

968
    for student in students.order_by("periods", "last_name", "first_name"):
1✔
969
        row = [student.full_name, student.username]
1✔
970
        periods = ", ".join([p.name for p in student.periods.filter(course=assignment.course)])
1✔
971
        row.append(periods)
1✔
972

973
        submissions = Submission.objects.filter(student=student, assignment=assignment)
1✔
974
        latest_submission = submissions.latest() if submissions else None
1✔
975
        publishes = PublishedSubmission.objects.filter(student=student, assignment=assignment)
1✔
976
        published_submission = publishes.latest().submission if publishes else latest_submission
1✔
977
        if published_submission is not None:
1!
978
            if published_submission.points_received:
1✔
979
                row.append(published_submission.points_received)
1✔
980
                row.append(published_submission.points)
1✔
981
                row.append(published_submission.formatted_grade)
1✔
982
            else:
983
                row.append("NG")
1✔
984
                row.append("NG")
1✔
985
                row.append("NG")
1✔
986
        else:
987
            row.append("M")
×
988
            row.append("M")
×
989
            row.append("M")
×
990
        writer.writerow(row)
1✔
991

992
    return response
1✔
993

994

995
@teacher_or_superuser_required
1✔
996
def download_submissions_view(request, assignment_id):
1✔
997
    """Download the submissions as a zipped file
998

999
    Args:
1000
        request: The request
1001
        assignment_id: The primary key of the :class:`.Assignment` model
1002
    """
1003
    assignment = get_object_or_404(
×
1004
        Assignment.objects.filter_editable(request.user), id=assignment_id
1005
    )
1006
    course = assignment.course
×
1007
    period = request.GET.get("period", "")
×
1008

1009
    if period == "all":
×
1010
        students = course.students.all()
×
1011
        name = f"assignment_{assignment.id}_all_submissions.zip"
×
1012
    elif course.period_set.exists():
×
1013
        period_obj = get_object_or_404(Period.objects.filter(course=course), id=int(period))
×
1014
        students = period_obj.students.all()
×
1015
        name = "assignment_{}_period_{}_submissions.zip".format(
×
1016
            assignment.id, period_obj.name.replace(" ", "_")
1017
        )
1018
    else:
1019
        raise http.Http404
×
1020

1021
    language = "P" if assignment.filename.endswith(".py") else "J"
×
1022
    extension = "java" if language == "J" else "py"
×
1023

1024
    s = BytesIO()
×
1025
    with zipfile.ZipFile(s, "w") as zf:
×
1026
        for student in students:
×
1027
            submissions = Submission.objects.filter(student=student, assignment=assignment)
×
1028
            latest_submission = submissions.latest() if submissions else None
×
1029
            publishes = PublishedSubmission.objects.filter(student=student, assignment=assignment)
×
1030
            published_submission = publishes.latest().submission if publishes else latest_submission
×
1031
            if published_submission is not None:
×
1032
                file_with_header = published_submission.file_text_with_header
×
1033
                zf.writestr(f"{student.username}.{extension}", file_with_header)
×
1034
    resp = http.HttpResponse(s.getvalue(), content_type="application/x-zip-compressed")
×
1035
    resp["Content-Disposition"] = f"attachment; filename={name}"
×
1036
    return resp
×
1037

1038

1039
@teacher_or_superuser_required
1✔
1040
def moss_view(request, assignment_id):
1✔
1041
    """Allows teachers to select submissions and send them to Moss.
1042

1043
    Args:
1044
        request: The request
1045
        assignment_id: The primary key of the :class:`.Assignment` model
1046
    """
1047
    assignment = get_object_or_404(
×
1048
        Assignment.objects.filter_editable(request.user), id=assignment_id
1049
    )
1050
    course = assignment.course
×
1051
    period = request.GET.get("period", "")
×
1052

1053
    if period and period != "all" and course.period_set.exists():
×
1054
        period = get_object_or_404(Period.objects.filter(course=course), id=int(period))
×
1055
    else:
1056
        period = None
×
1057

1058
    errors = ""
×
1059
    filename = assignment.filename
×
1060

1061
    if request.method == "POST":
×
1062
        form = MossForm(assignment, period, request.POST, request.FILES)
×
1063
        if form.is_valid():
×
1064
            moss_result = form.save(commit=False)
×
1065
            moss_result.assignment = assignment
×
1066
            moss_result.filename = filename
×
1067
            moss_result.save()
×
1068
            run_moss.delay(moss_result.id)
×
1069
        return redirect(
×
1070
            reverse("assignments:moss", kwargs={"assignment_id": assignment_id})
1071
            + (f"?period={period.id}" if period else "")
1072
        )
1073

1074
    form = MossForm(assignment, period)
×
1075

1076
    past_results = assignment.moss_results.order_by("-date")
×
1077

1078
    return render(
×
1079
        request,
1080
        "assignments/moss.html",
1081
        {
1082
            "form": form,
1083
            "errors": errors,
1084
            "past_results": past_results,
1085
            "course": course,
1086
            "folder": assignment.folder,
1087
            "assignment": assignment,
1088
            "nav_item": "Run Moss",
1089
        },
1090
    )
1091

1092

1093
@teacher_or_superuser_required
1✔
1094
def download_log_view(request, assignment_id):
1✔
1095
    """Download the log messages
1096

1097
    Args:
1098
        request: The request
1099
        assignment_id: The primary key of the :class:`.Assignment` model
1100
    """
1101
    assignment = get_object_or_404(
×
1102
        Assignment.objects.filter_editable(request.user), id=assignment_id
1103
    )
1104

1105
    log_file_name = os.path.join(settings.MEDIA_ROOT, assignment.grader_log_filename)
×
1106

1107
    if (
×
1108
        request.user not in assignment.course.teacher.all() and not request.user.is_superuser
1109
    ) or not os.path.exists(log_file_name):
1110
        raise http.Http404
×
1111

1112
    assigment_dir = os.path.dirname(log_file_name)
×
1113

1114
    args = sandboxing.get_assignment_sandbox_args(
×
1115
        ["cat", "--", log_file_name],
1116
        network_access=False,
1117
        whitelist=[assigment_dir],
1118
        read_only=[assigment_dir],
1119
    )
1120

1121
    try:
×
1122
        res = subprocess.run(
×
1123
            args,
1124
            capture_output=True,
1125
            text=True,
1126
            check=True,
1127
        )
1128
    except FileNotFoundError as e:
×
1129
        logger.error("Cannot run processes: %s", e)
×
1130
        raise FileNotFoundError from e
×
1131

1132
    data = res.stdout
×
1133

1134
    response = http.HttpResponse(data, content_type="text/plain")
×
1135
    response["Content-Disposition"] = (
×
1136
        f'attachment; filename="{slugify(assignment.name)}-grader.log"'
1137
    )
1138

1139
    return response
×
1140

1141

1142
@login_required
1✔
1143
def show_folder_view(request, course_id, folder_id):
1✔
1144
    """Show a folder
1145

1146
    Args:
1147
        request: The request
1148
        course_id: The primary key of the :class:`.Course` model
1149
        folder_id: The primary key of the :class:`.Folder` model
1150
    """
1151
    course = get_object_or_404(Course.objects.filter_visible(request.user), id=course_id)
×
1152
    folder = get_object_or_404(course.folders.all(), id=folder_id)
×
1153

1154
    assignments = course.assignments.filter(folder=folder).filter_visible(request.user)
×
1155
    if course.sort_assignments_by == "due_date":
×
1156
        assignments = assignments.order_by("-due")
×
1157
    elif course.sort_assignments_by == "name":
×
1158
        assignments = assignments.order_by("name")
×
1159

1160
    context = {
×
1161
        "course": course,
1162
        "folder": folder,
1163
        "assignments": assignments,
1164
        "period": course.period_set.filter(students=request.user),
1165
        "is_student": course.is_student_in_course(request.user),
1166
        "is_teacher": request.user in course.teacher.all(),
1167
    }
1168
    if course.is_student_in_course(request.user):
×
1169
        context["unsubmitted_assignments"] = assignments.exclude(submissions__student=request.user)
×
1170

1171
    return render(request, "assignments/show_folder.html", context=context)
×
1172

1173

1174
@teacher_or_superuser_required
1✔
1175
def create_folder_view(request, course_id):
1✔
1176
    """Create a folder
1177

1178
    Args:
1179
        request: The request
1180
        course_id: The primary key of the :class:`.Course` model
1181
    """
1182
    course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
1✔
1183

1184
    if request.method == "POST":
1!
1185
        form = FolderForm(request.POST)
1✔
1186
        if form.is_valid():
1!
1187
            folder = form.save(commit=False)
1✔
1188
            folder.course = course
1✔
1189
            folder.save()
1✔
1190
            return redirect("courses:show", course.id)
1✔
1191

1192
    form = FolderForm()
×
1193
    context = {
×
1194
        "form": form,
1195
        "nav_item": "Create folder",
1196
        "course": course,
1197
        "action": "add",
1198
    }
1199

1200
    return render(request, "assignments/edit_create_folder.html", context=context)
×
1201

1202

1203
@teacher_or_superuser_required
1✔
1204
def edit_folder_view(request, course_id, folder_id):
1✔
1205
    """Edit a folder
1206

1207
    Args:
1208
        request: The request
1209
        course_id: The primary key of the :class:`.Course` model
1210
        folder_id: The primary key of the :class:`.Folder` model
1211
    """
1212
    course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
×
1213
    folder = get_object_or_404(course.folders.all(), id=folder_id)
×
1214

1215
    if request.method == "POST":
×
1216
        form = FolderForm(request.POST, instance=folder)
×
1217
        if form.is_valid():
×
1218
            form.save()
×
1219
            return redirect("assignments:show_folder", course.id, folder.id)
×
1220

1221
    form = FolderForm(instance=folder)
×
1222
    context = {
×
1223
        "form": form,
1224
        "nav_item": "Edit",
1225
        "course": course,
1226
        "folder": folder,
1227
        "action": "edit",
1228
    }
1229

1230
    return render(request, "assignments/edit_create_folder.html", context=context)
×
1231

1232

1233
@teacher_or_superuser_required
1✔
1234
def delete_folder_view(request, course_id, folder_id):
1✔
1235
    """Delete a folder
1236

1237
    Args:
1238
        request: The request
1239
        course_id: The primary key of the :class:`.Course` model
1240
        folder_id: The primary key of the :class:`.Folder` model
1241
    """
1242
    course = get_object_or_404(Course.objects.filter_editable(request.user), id=course_id)
1✔
1243
    folder = get_object_or_404(course.folders.all(), id=folder_id)
1✔
1244

1245
    folder.delete()
1✔
1246
    return redirect("courses:show", course.id)
1✔
1247

1248

1249
@require_POST
1✔
1250
@teacher_or_superuser_required
1✔
1251
def upload_image(request):
1✔
1252
    form = ImageForm(request.POST, request.FILES)
×
1253
    if form.is_valid():
×
1254
        image = form.cleaned_data["image"]
×
1255
        image_name = Path(image.name)
×
1256
        uuid_image_name = image_name.with_stem(f"{image_name.stem}-{uuid.uuid4()}")
×
1257
        static_location = Path("uploaded-media") / uuid_image_name
×
1258
        dest = (
×
1259
            Path(settings.STATICFILES_DIRS[0]) if settings.DEBUG else Path(settings.STATIC_ROOT)
1260
        ) / static_location
1261
        dest.parent.mkdir(exist_ok=True)
×
1262
        with dest.open("wb+") as f:
×
1263
            for chunk in image.chunks():
×
1264
                f.write(chunk)
×
1265
        return http.JsonResponse(
×
1266
            {
1267
                "url": request.build_absolute_uri(
1268
                    f"{settings.STATIC_URL.removesuffix('/')}/{static_location}"
1269
                ),
1270
                "title": image_name.stem,
1271
            }
1272
        )
1273
    return http.JsonResponse(form.errors, status=400)
×
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