Skip to content

Commit 219e614

Browse files
committed
Add PrairieTest integration
1 parent ee008ad commit 219e614

19 files changed

+1098
-18
lines changed

.ci/run-mypy.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#! /bin/bash
22

3-
mypy relate course accounts
3+
mypy relate course accounts prairietest

.ci/run-tests-for-ci.sh

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@
22

33
set -e
44

5-
PY_EXE=${PY_EXE:-$(poetry run which python)}
6-
7-
echo "-----------------------------------------------"
8-
echo "Current directory: $(pwd)"
9-
echo "Python executable: ${PY_EXE}"
10-
echo "-----------------------------------------------"
11-
125
echo "i18n"
136
# Testing i18n needs a local_settings file even though the rest of the tests
147
# don't use it

course/admin.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
2323
THE SOFTWARE.
2424
"""
2525

26-
from typing import Any
26+
from typing import TYPE_CHECKING, Any
2727

2828
from django import forms
2929
from django.contrib import admin
30+
from django.db.models import QuerySet
3031
from django.utils.translation import gettext_lazy as _, pgettext
3132

3233
from course.constants import exam_ticket_states, participation_permission as pperm
@@ -56,9 +57,13 @@
5657
from relate.utils import string_concat
5758

5859

60+
if TYPE_CHECKING:
61+
from accounts.models import User
62+
63+
5964
# {{{ permission helpers
6065

61-
def _filter_courses_for_user(queryset, user):
66+
def _filter_courses_for_user(queryset: QuerySet, user: User) -> QuerySet:
6267
if user.is_superuser:
6368
return queryset
6469
z = queryset.filter(
@@ -67,7 +72,7 @@ def _filter_courses_for_user(queryset, user):
6772
return z
6873

6974

70-
def _filter_course_linked_obj_for_user(queryset, user):
75+
def _filter_course_linked_obj_for_user(queryset: QuerySet, user: User) -> QuerySet:
7176
if user.is_superuser:
7277
return queryset
7378
return queryset.filter(
@@ -76,7 +81,9 @@ def _filter_course_linked_obj_for_user(queryset, user):
7681
)
7782

7883

79-
def _filter_participation_linked_obj_for_user(queryset, user):
84+
def _filter_participation_linked_obj_for_user(
85+
queryset: QuerySet, user: User
86+
) -> QuerySet:
8087
if user.is_superuser:
8188
return queryset
8289
return queryset.filter(

course/utils.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
parse_date_spec,
5555
)
5656
from course.page.base import PageBase, PageContext
57-
from prairietest.utils import has_access_to_exam
5857
from relate.utils import (
5958
RelateHttpRequest,
6059
not_none,
@@ -164,6 +163,8 @@ def _eval_generic_conditions(
164163
now_datetime: datetime.datetime,
165164
flow_id: str,
166165
login_exam_ticket: ExamTicket | None,
166+
*,
167+
remote_ip_address: IPv4Address | IPv6Address | None = None,
167168
) -> bool:
168169

169170
if hasattr(rule, "if_before"):
@@ -189,6 +190,22 @@ def _eval_generic_conditions(
189190
if login_exam_ticket.exam.flow_id != flow_id:
190191
return False
191192

193+
if hasattr(rule, "if_has_prairietest_exam_access"):
194+
if remote_ip_address is None:
195+
return False
196+
if participation is None:
197+
return False
198+
199+
from prairietest.utils import has_access_to_exam
200+
if not has_access_to_exam(
201+
course,
202+
participation.user.email,
203+
rule.if_has_prairietest_exam_access,
204+
now_datetime,
205+
remote_ip_address,
206+
):
207+
return False
208+
192209
return True
193210

194211

@@ -313,7 +330,8 @@ def get_session_start_rule(
313330
for rule in rules:
314331
if not _eval_generic_conditions(rule, course, participation,
315332
now_datetime, flow_id=flow_id,
316-
login_exam_ticket=login_exam_ticket):
333+
login_exam_ticket=login_exam_ticket,
334+
remote_ip_address=remote_ip_address):
317335
continue
318336

319337
if not _eval_participation_tags_conditions(rule, participation):
@@ -401,9 +419,11 @@ def get_session_access_rule(
401419

402420
for rule in rules:
403421
if not _eval_generic_conditions(
404-
rule, session.course, session.participation,
405-
now_datetime, flow_id=session.flow_id,
406-
login_exam_ticket=login_exam_ticket):
422+
rule, session.course, session.participation,
423+
now_datetime, flow_id=session.flow_id,
424+
login_exam_ticket=login_exam_ticket,
425+
remote_ip_address=remote_ip_address,
426+
):
407427
continue
408428

409429
if not _eval_participation_tags_conditions(rule, session.participation):
@@ -1050,7 +1070,7 @@ class FacilityFindingMiddleware:
10501070
def __init__(self, get_response):
10511071
self.get_response = get_response
10521072

1053-
def __call__(self, request):
1073+
def __call__(self, request: http.HttpRequest) -> http.HttpResponse:
10541074
pretend_facilities = request.session.get("relate_pretend_facilities")
10551075

10561076
if pretend_facilities is not None:
@@ -1071,6 +1091,7 @@ def __call__(self, request):
10711091
if remote_address in ip_network(str(ir)):
10721092
facilities.add(name)
10731093

1094+
request = cast(RelateHttpRequest, request)
10741095
request.relate_facilities = frozenset(facilities)
10751096

10761097
return self.get_response(request)

course/validation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ def validate_session_start_rule(
576576
("if_has_fewer_sessions_than", int),
577577
("if_has_fewer_tagged_sessions_than", int),
578578
("if_signed_in_with_matching_exam_ticket", bool),
579+
("if_has_prairietest_exam_access", str),
579580
("tag_session", (str, type(None))),
580581
("may_start_new_session", bool),
581582
("may_list_existing_sessions", bool),
@@ -673,6 +674,7 @@ def validate_session_access_rule(
673674
("if_expiration_mode", str),
674675
("if_session_duration_shorter_than_minutes", (int, float)),
675676
("if_signed_in_with_matching_exam_ticket", bool),
677+
("if_has_prairietest_exam_access", str),
676678
("message", str),
677679
]
678680
)

local_settings_example.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,17 @@
471471
# "exams_only": True,
472472
# },
473473
# }
474+
#
475+
# # Automatically get denied facilities from PrairieTest
476+
# result = {}
477+
#
478+
# from prairietest.utils import denied_ip_networks_at
479+
# pt_facilities_networks = denied_ip_networks_at(now_datetime)
480+
# for (course_id, facility_name), networks in pt_facilities_networks.items():
481+
# fdata = result.setdefault(facility_name, {})
482+
# fdata["exams_only"] = True
483+
# fdata["ip_ranges"] = [*fdata.get("ip_ranges", []), *networks]
484+
# return result
474485

475486

476487
RELATE_FACILITIES = {

prairietest/__init__.py

Whitespace-only changes.

prairietest/admin.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
4+
__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees"
5+
6+
__license__ = """
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in
15+
all copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
THE SOFTWARE.
24+
"""
25+
26+
from typing import TYPE_CHECKING
27+
28+
from django import forms, http
29+
from django.contrib import admin
30+
from django.db.models import QuerySet
31+
from django.urls import reverse
32+
from django.utils.safestring import mark_safe
33+
34+
from accounts.models import User
35+
from course.constants import participation_permission as pperm
36+
from prairietest.models import AllowEvent, DenyEvent, Facility, MostRecentDenyEvent
37+
38+
39+
if TYPE_CHECKING:
40+
from accounts.models import User
41+
42+
43+
class FacilityAdminForm(forms.ModelForm):
44+
class Meta:
45+
model = Facility
46+
fields = "__all__"
47+
widgets = {
48+
"secret": forms.PasswordInput(render_value=True),
49+
}
50+
51+
52+
@admin.register(Facility)
53+
class FacilityAdmin(admin.ModelAdmin):
54+
def get_queryset(self, request: http.HttpRequest) -> QuerySet:
55+
assert request.user.is_authenticated
56+
57+
qs = super().get_queryset(request)
58+
if request.user.is_superuser:
59+
return qs
60+
from course.admin import _filter_course_linked_obj_for_user
61+
return _filter_course_linked_obj_for_user(qs, request.user)
62+
63+
def webhook_url(self, obj: Facility) -> str:
64+
url = reverse(
65+
"prairietest:webhook",
66+
args=(obj.course.identifier, obj.identifier),
67+
)
68+
return mark_safe(
69+
f"<tt>https://YOUR-HOST{url}</tt> Make sure to include the trailing slash!")
70+
71+
list_display = ["course", "identifier", "webhook_url"]
72+
list_display_links = ["identifier"]
73+
list_filter = ["course", "identifier"]
74+
75+
form = FacilityAdminForm
76+
77+
78+
def _filter_events_for_user(queryset: QuerySet, user: User) -> QuerySet:
79+
if user.is_superuser:
80+
return queryset
81+
return queryset.filter(
82+
facility__course__participations__user=user,
83+
facility__course__participations__roles__permissions__permission=pperm.use_admin_interface)
84+
85+
86+
@admin.register(AllowEvent)
87+
class AllowEventAdmin(admin.ModelAdmin):
88+
def get_queryset(self, request: http.HttpRequest) -> QuerySet:
89+
assert request.user.is_authenticated
90+
91+
qs = super().get_queryset(request)
92+
return _filter_events_for_user(qs, request.user)
93+
94+
list_display = [
95+
"event_id", "facility", "user_uid", "start", "end", "exam_uuid"]
96+
list_filter = ["facility", "user_uid", "exam_uuid"]
97+
98+
99+
@admin.register(DenyEvent)
100+
class DenyEventAdmin(admin.ModelAdmin):
101+
def get_queryset(self, request: http.HttpRequest) -> QuerySet:
102+
assert request.user.is_authenticated
103+
104+
qs = super().get_queryset(request)
105+
return _filter_events_for_user(qs, request.user)
106+
107+
list_display = [
108+
"event_id", "facility", "start", "end", "deny_uuid"]
109+
list_filter = ["facility"]
110+
111+
112+
@admin.register(MostRecentDenyEvent)
113+
class MostRecentDenyEventAdmin(admin.ModelAdmin):
114+
def get_queryset(self, request: http.HttpRequest) -> QuerySet:
115+
assert request.user.is_authenticated
116+
117+
qs = super().get_queryset(request)
118+
if request.user.is_superuser:
119+
return qs
120+
return qs.filter(
121+
event__facility__course__participations__user=request.user,
122+
event__facility__course__participations__roles__permissions__permission=pperm.use_admin_interface)
123+
124+
list_display = ["deny_uuid", "end"]

0 commit comments

Comments
 (0)