diff --git a/api/urls.py b/api/urls.py index c4362fcef..a06fb17d6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -8,6 +8,7 @@ path("tasks/", default_view.task_detail), path("tasks//duplicate", default_view.duplicate_task), path("tasks/", default_view.task_detail), + path("classrooms-list/", default_view.classrooms_list), path("task-list", default_view.tasks_list_all), path("task-list/", default_view.tasks_list_all), path("student-list", default_view.student_list), diff --git a/api/views/default.py b/api/views/default.py index fce7f72db..5cae7019f 100644 --- a/api/views/default.py +++ b/api/views/default.py @@ -47,8 +47,15 @@ assignedtask_results, current_semester, submit_assignment_path, + ClassroomIpRange, +) +from common.submit import ( + SubmitRateLimited, + store_submit, + SubmitPastHardDeadline, + SubmitAfterFinal, + SubmitFromUnauthorizedIPError, ) -from common.submit import SubmitRateLimited, store_submit, SubmitPastHardDeadline, SubmitAfterFinal from common.upload import MAX_UPLOAD_FILECOUNT, TooManyFilesError from common.utils import is_teacher, points_to_color, inbus_search_user, user_from_inbus_person from quiz.models import EnrolledQuiz @@ -597,6 +604,12 @@ def add_student_to_class(request, class_id): ) +def classrooms_list(request): + classrooms = ClassroomIpRange.objects.values("id", "name") + + return JsonResponse(list(classrooms), safe=False) + + @user_passes_test(is_teacher) def task_detail(request, task_id=None): errors = [] @@ -719,7 +732,7 @@ def set_subject(task): for cl in data["classes"]: if cl.get("assigned", None): - AssignedTask.objects.update_or_create( + assigned_task, _ = AssignedTask.objects.update_or_create( task_id=task.pk, clazz_id=cl["id"], defaults={ @@ -731,6 +744,15 @@ def set_subject(task): "hard_deadline": cl.get("hard_deadline", False), }, ) + + if "allowed_classrooms" in cl: + classes = [] + for class_id in cl["allowed_classrooms"]: + class_object = ClassroomIpRange.objects.get(pk=class_id) + classes.append(class_object) + + assigned_task.allowed_classrooms.set(classes) + else: submits = Submit.objects.filter( assignment__task_id=task.pk, assignment__clazz_id=cl["id"] @@ -856,6 +878,9 @@ def is_allowed(path): item["deadline"] = assigned.deadline item["max_points"] = assigned.max_points item["hard_deadline"] = assigned.hard_deadline + item["allowed_classrooms"] = list( + assigned.allowed_classrooms.values_list("id", flat=True) + ) result["classes"].append(item) @@ -1024,6 +1049,12 @@ def create_submit(request: django.http.HttpRequest, task_assignment: int) -> Jso "error": "The submission was sent after the final one, so it is not a valid submission." } ) + except SubmitFromUnauthorizedIPError: + return JsonResponse( + { + "error": "The submission was sent from IP address that is not on the list of allowed IP addresses." + } + ) url = ( reverse( diff --git a/common/admin.py b/common/admin.py index 9d9e2ae0f..b0bdd346e 100644 --- a/common/admin.py +++ b/common/admin.py @@ -1,7 +1,11 @@ +import ipaddress + from django.contrib import admin from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User +from django import forms + import common.models as models import common.utils @@ -82,6 +86,8 @@ class AssignedTaskAdmin(admin.ModelAdmin): autocomplete_fields = ["task", "clazz"] search_fields = ["task__name", "clazz__teacher__username", "clazz__subject_abbr"] + filter_horizontal = ["allowed_classrooms"] + def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == "clazz": kwargs["queryset"] = models.Class.objects.current_semester() @@ -138,6 +144,56 @@ class SubmitAdmin(admin.ModelAdmin): autocomplete_fields = ["assignment"] +class ClassroomAdminForm(forms.ModelForm): + use_cidr = forms.BooleanField(required=False, label="Enter CIDR instead of range") + + ip_range_start = forms.GenericIPAddressField(required=False) + ip_range_end = forms.GenericIPAddressField(required=False) + cidr = forms.CharField(required=False, label="CIDR address") + + class Meta: + model = models.ClassroomIpRange + fields = ("name", "ip_range_start", "ip_range_end", "cidr") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance and self.instance.pk: + start_ip = ipaddress.ip_address(self.instance.ip_range_start) + end_ip = ipaddress.ip_address(self.instance.ip_range_end) + + cidr = list(ipaddress.summarize_address_range(start_ip, end_ip))[0] + + self.fields["cidr"].initial = str(cidr) + + def clean(self): + cleaned_data = super().clean() + + if cleaned_data.get("name") is None: + raise forms.ValidationError("Please enter classroom code") + + cidr = cleaned_data.get("use_cidr") + + if cidr: + cidr_value = cleaned_data.get("cidr") + if not cidr_value: + raise forms.ValidationError("Cannot read CIDR field value") + + network = ipaddress.ip_network(cidr_value, strict=False) + + cleaned_data["ip_range_start"] = network.network_address + cleaned_data["ip_range_end"] = network.broadcast_address + else: + if not cleaned_data.get("ip_range_start") or not cleaned_data.get("ip_range_end"): + raise forms.ValidationError("You didn't enter IP range") + + return cleaned_data + + +class ClassroomAdmin(admin.ModelAdmin): + form = ClassroomAdminForm + + admin.site.register(models.Task, TaskAdmin) admin.site.register(models.Class, ClassAdmin) admin.site.register(models.Submit, SubmitAdmin) @@ -145,6 +201,7 @@ class SubmitAdmin(admin.ModelAdmin): admin.site.register(models.Semester) admin.site.register(models.Subject) admin.site.register(models.UserEventModel) +admin.site.register(models.ClassroomIpRange, ClassroomAdmin) admin.site.unregister(User) admin.site.register(User, MyUserAdmin) diff --git a/common/migrations/0030_classroomiprange_assignedtask_allowed_classrooms.py b/common/migrations/0030_classroomiprange_assignedtask_allowed_classrooms.py new file mode 100644 index 000000000..5de87f627 --- /dev/null +++ b/common/migrations/0030_classroomiprange_assignedtask_allowed_classrooms.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2025-10-23 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0029_submit_is_final_alter_usereventmodel_action'), + ] + + operations = [ + migrations.CreateModel( + name='ClassroomIpRange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('ip_range_start', models.GenericIPAddressField()), + ('ip_range_end', models.GenericIPAddressField()), + ], + ), + migrations.AddField( + model_name='assignedtask', + name='allowed_classrooms', + field=models.ManyToManyField(related_name='assignments', to='common.classroomiprange'), + ), + ] diff --git a/common/models.py b/common/models.py index 8497478e8..350f7f055 100644 --- a/common/models.py +++ b/common/models.py @@ -1,3 +1,4 @@ +import ipaddress import os import re import logging @@ -222,6 +223,15 @@ class Meta: verbose_name_plural = "classes" +class ClassroomIpRange(models.Model): + name = models.TextField() + ip_range_start = models.GenericIPAddressField() + ip_range_end = models.GenericIPAddressField() + + def __str__(self): + return f"{self.name}: {self.ip_range_start} – {self.ip_range_end}" + + class AssignedTask(models.Model): task = models.ForeignKey(Task, on_delete=models.CASCADE) clazz = models.ForeignKey(Class, on_delete=models.CASCADE) @@ -230,6 +240,7 @@ class AssignedTask(models.Model): hard_deadline = models.BooleanField(default=False) max_points = models.IntegerField(null=True, blank=True) moss_url = models.URLField(null=True, blank=True, editable=False) + allowed_classrooms = models.ManyToManyField(ClassroomIpRange, related_name="assignments") def is_visible(self): return timezone.now() >= self.assigned @@ -243,6 +254,22 @@ def is_past_deadline(self): and datetime.datetime.now(datetime.timezone.utc) > self.deadline ) + def is_allowed_from_ip(self, ip: str) -> bool: + ip = ipaddress.ip_address(ip) + + if not self.allowed_classrooms.all().exists(): + return True + + allowed = False + + for classroom in self.allowed_classrooms.all(): + start = ipaddress.ip_address(classroom.ip_range_start) + end = ipaddress.ip_address(classroom.ip_range_end) + + allowed |= start <= ip <= end + + return allowed + def __str__(self): return f"{self.task.name} {self.clazz}" diff --git a/common/submit.py b/common/submit.py index cbcb188ce..524199c87 100644 --- a/common/submit.py +++ b/common/submit.py @@ -35,6 +35,11 @@ def __init__(self, message: str): super().__init__(message) +class SubmitFromUnauthorizedIPError(Exception): + def __init__(self, message: str): + super().__init__(message) + + def store_submit(request: HttpRequest, assignment: AssignedTask) -> Submit: """ Creates a new submit for the given `assignment` and the user logged in the `request`. @@ -81,6 +86,12 @@ def store_submit(request: HttpRequest, assignment: AssignedTask) -> Submit: if client_ip: s.ip_address = client_ip + if assignment.allowed_classrooms: + if not assignment.is_allowed_from_ip(client_ip): + raise SubmitFromUnauthorizedIPError( + "It is forbidden to upload solutions from this IP address" + ) + solutions = request.FILES.getlist("solution") tmp = request.POST.get("paths", None) if tmp: diff --git a/common/utils.py b/common/utils.py index aea9feba2..53e812e9b 100644 --- a/common/utils.py +++ b/common/utils.py @@ -1,5 +1,10 @@ from datetime import timedelta + +from django.core.exceptions import PermissionDenied from django.http import HttpRequest +from django.http.response import Http404 +from django.utils import timezone + from .inbus import inbus import django.contrib.auth.models import re @@ -81,3 +86,56 @@ def get_client_ip_address(request: HttpRequest) -> IPAddressString | None: return None else: return IPAddressString(client_ip) + + +def ip_address_check(function): + """ + Decorator that restricts access to specific IP addresses. + + The decorated function must have the following parameters: + - request + - assignment_id + + Access is granted if any of the following conditions are met: + - requesting user is a teacher + - assignment deadline has passed + - assignment has no IP address restrictions + - user is accessing from an allowed IP address + + IP address check is performed using: + models.AssignedTask.is_allowed_from_ip(str) + """ + + def wrapper(*args, **kwargs): + # this import is here to prevent cyclic dependency (.models uses is_teacher) + from .models import AssignedTask + + request = args[0] + + if is_teacher(request.user): + return function(*args, **kwargs) + + assignment_id = kwargs.get("assignment_id") + + try: + assignment = AssignedTask.objects.get(pk=assignment_id) + except AssignedTask.DoesNotExist: + raise Http404(f"AssignedTask with id {assignment_id} not found") + + # allow after deadline + if assignment.deadline is not None and timezone.now() > assignment.deadline: + return function(*args, **kwargs) + + if assignment.allowed_classrooms: + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + ip = x_forwarded_for.split(",")[0].strip() + else: + ip = request.META.get("REMOTE_ADDR") + + if assignment.is_allowed_from_ip(ip): + return function(*args, **kwargs) + else: + raise PermissionDenied("Access from this IP is not allowed") + + return wrapper diff --git a/frontend/src/ClassroomsSelect.svelte b/frontend/src/ClassroomsSelect.svelte new file mode 100644 index 000000000..4c4df64ee --- /dev/null +++ b/frontend/src/ClassroomsSelect.svelte @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/src/EditTask.svelte b/frontend/src/EditTask.svelte index 58301600b..61c954a83 100644 --- a/frontend/src/EditTask.svelte +++ b/frontend/src/EditTask.svelte @@ -10,6 +10,7 @@ import { fs, currentPath, cwd, openedFiles } from './fs.js'; import SyncLoader from './SyncLoader.svelte'; import Modal from './Modal.svelte'; import { task_types } from './taskTypes'; +import ClassroomsSelect from './ClassroomsSelect.svelte'; export let params = {}; @@ -189,6 +190,15 @@ function assignHardDeadlineToAll(hard_deadline) { }); } +function assignClassesToAll(classes) { + task.classes = task.classes.map((cl) => { + if (cl.assigned) { + cl.allowed_classrooms = structuredClone(classes); + } + return cl; + }); +} + function assignSameToAll(templateClass) { task.classes = task.classes.map((cl) => { if (isClassVisible(cl)) { @@ -196,6 +206,7 @@ function assignSameToAll(templateClass) { cl.assigned = templateClass.assigned; cl.deadline = templateClass.deadline; cl.hard_deadline = templateClass.hard_deadline; + cl.allowed_classrooms = structuredClone(templateClass.allowed_classrooms); } return cl; }); @@ -334,9 +345,9 @@ async function deleteTask(proceed) { onToRelativeClick={setRelativeDeadlineToAssigned} /> {#if clazz.deadline}
-
+
{/if} -
+
+ {#if task.type === 'exam' && clazz.assigned} +
+ +
+ {/if}
diff --git a/frontend/src/TimeRange.svelte b/frontend/src/TimeRange.svelte index 5afc90609..5b51a903f 100644 --- a/frontend/src/TimeRange.svelte +++ b/frontend/src/TimeRange.svelte @@ -58,63 +58,67 @@ $: if (instanceTo) { } -
- - - - +
+
+ + + + +
-
- - - - - +
+
+ + + + + +
diff --git a/web/views/student.py b/web/views/student.py index 462a21201..4f2e7c91d 100644 --- a/web/views/student.py +++ b/web/views/student.py @@ -53,7 +53,7 @@ from common.plagcheck.moss import PlagiarismMatch, moss_result from common.submit import SubmitRateLimited, store_submit, SubmitPastHardDeadline from common.upload import MAX_UPLOAD_FILECOUNT, TooManyFilesError -from common.utils import is_teacher +from common.utils import is_teacher, ip_address_check from evaluator.results import EvaluationResult from evaluator.testsets import TestSet from kelvin.settings import BASE_DIR, MAX_INLINE_CONTENT_BYTES, MAX_INLINE_LINES @@ -314,6 +314,7 @@ def build(match: PlagiarismMatch) -> PlagiarismEntry: @login_required() +@ip_address_check def task_detail(request, assignment_id, submit_num=None, login=None): submits = Submit.objects.filter( assignment__pk=assignment_id, @@ -545,6 +546,7 @@ def submit_source(request, submit_id, path): @login_required +@ip_address_check def submit_diff(request, login, assignment_id, submit_a, submit_b): submit = get_object_or_404( Submit, assignment_id=assignment_id, student__username=login, submit_num=submit_a @@ -608,6 +610,7 @@ def get_patch(p1, p2): @login_required +@ip_address_check def submit_comments(request, assignment_id, login, submit_num): submit = get_object_or_404( Submit, assignment_id=assignment_id, student__username=login, submit_num=submit_num @@ -1045,11 +1048,13 @@ def submit_download(request, assignment_id: int, login: str, submit_num: int): @login_required +@ip_address_check def ui(request): return render(request, "web/ui.html") @csrf_exempt +@ip_address_check def upload_results(request, assignment_id, submit_num, login): submit = get_object_or_404( Submit, assignment_id=assignment_id, submit_num=submit_num, student__username=login @@ -1079,6 +1084,7 @@ def upload_results(request, assignment_id, submit_num, login): @login_required() +@ip_address_check def mark_solution_as_final(request, assignment_id, login, submit_num): submit = get_object_or_404( Submit, assignment_id=assignment_id, submit_num=submit_num, student__username=login