From 6bb94417489f384d67e9d8317fe05f8b99b7d132 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 10 Jun 2026 22:05:12 +0100 Subject: [PATCH] smoke test the rest of the apps --- anatomy/filters.py | 5 +- anatomy/tests/test_url_smoke.py | 122 +++++++++++++ anatomy/views.py | 2 + atlas/tests/__init__.py | 1 + generic/models.py | 35 ++-- generic/templates/generic/help.html | 14 ++ generic/tests/test_url_smoke.py | 117 +++++++++++++ generic/views.py | 14 +- generic/widgets.py | 15 +- longs/filters.py | 5 +- longs/tests.py | 3 - longs/tests/__init__.py | 1 + longs/tests/test_url_smoke.py | 163 ++++++++++++++++++ longs/views.py | 50 +----- media_test/tmpam34uuxi.jpg | 0 media_test/tmpn6geswcc.jpg | 0 media_test/tmpqmjkbq5c.jpg | 0 media_testanatomy/exam/1.json | 1 + oef/templates/oef/formats_update_form.html | 5 +- oef/tests.py | 3 - oef/tests/__init__.py | 1 + oef/tests/test_url_smoke.py | 95 ++++++++++ physics/filters.py | 5 +- physics/tests.py | 3 - physics/tests/__init__.py | 1 + physics/tests/test_url_smoke.py | 132 ++++++++++++++ physics/views.py | 12 +- rad/settings_test.py | 5 + rapids/filters.py | 9 +- rapids/tests/__init__.py | 1 + rapids/tests/test_url_smoke.py | 163 ++++++++++++++++++ rapids/views.py | 5 +- rcr/tests.py | 3 - rcr/tests/__init__.py | 1 + rcr/tests/conftest.py | 6 +- rcr/tests/test_url_smoke.py | 97 +++++++++++ rota/templates/rota/userrotadetail_form.html | 14 ++ rota/tests.py | 3 - rota/tests/__init__.py | 1 + rota/tests/test_url_smoke.py | 65 +++++++ sbas/filters.py | 5 +- .../sbas/question_confirm_delete.html | 7 + sbas/tests.py | 3 - sbas/tests/__init__.py | 1 + sbas/tests/test_url_smoke.py | 131 ++++++++++++++ sbas/views.py | 2 + shorts/filters.py | 9 +- .../shorts/question_confirm_delete.html | 7 + shorts/templates/shorts/question_viewer.html | 8 + .../shorts/user_answer_question_view.html | 31 ++++ .../templates/shorts/useranswer_detail.html | 12 ++ shorts/tests.py | 3 - shorts/tests/__init__.py | 1 + shorts/tests/test_url_smoke.py | 151 ++++++++++++++++ shorts/views.py | 14 +- 55 files changed, 1454 insertions(+), 109 deletions(-) create mode 100644 anatomy/tests/test_url_smoke.py create mode 100644 atlas/tests/__init__.py create mode 100644 generic/templates/generic/help.html create mode 100644 generic/tests/test_url_smoke.py delete mode 100755 longs/tests.py create mode 100644 longs/tests/__init__.py create mode 100644 longs/tests/test_url_smoke.py create mode 100644 media_test/tmpam34uuxi.jpg create mode 100644 media_test/tmpn6geswcc.jpg create mode 100644 media_test/tmpqmjkbq5c.jpg create mode 100644 media_testanatomy/exam/1.json delete mode 100644 oef/tests.py create mode 100644 oef/tests/__init__.py create mode 100644 oef/tests/test_url_smoke.py delete mode 100644 physics/tests.py create mode 100644 physics/tests/__init__.py create mode 100644 physics/tests/test_url_smoke.py create mode 100644 rapids/tests/__init__.py create mode 100644 rapids/tests/test_url_smoke.py delete mode 100644 rcr/tests.py create mode 100644 rcr/tests/__init__.py create mode 100644 rcr/tests/test_url_smoke.py create mode 100644 rota/templates/rota/userrotadetail_form.html delete mode 100644 rota/tests.py create mode 100644 rota/tests/__init__.py create mode 100644 rota/tests/test_url_smoke.py create mode 100644 sbas/templates/sbas/question_confirm_delete.html delete mode 100644 sbas/tests.py create mode 100644 sbas/tests/__init__.py create mode 100644 sbas/tests/test_url_smoke.py create mode 100644 shorts/templates/shorts/question_confirm_delete.html create mode 100644 shorts/templates/shorts/question_viewer.html create mode 100644 shorts/templates/shorts/user_answer_question_view.html create mode 100644 shorts/templates/shorts/useranswer_detail.html delete mode 100644 shorts/tests.py create mode 100644 shorts/tests/__init__.py create mode 100644 shorts/tests/test_url_smoke.py diff --git a/anatomy/filters.py b/anatomy/filters.py index a4748a71..ab71f568 100644 --- a/anatomy/filters.py +++ b/anatomy/filters.py @@ -61,8 +61,9 @@ class AnatomyUserAnswerFilter(django_filters.FilterSet): ) def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): - if not request.user.groups.filter(name="anatomy_checker").exists(): - queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id) + if not request.user.is_superuser: + if not request.user.groups.filter(name="anatomy_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id) #queryset = queryset.prefetch_related("question", "question__question_type", "exam") super(AnatomyUserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) pass \ No newline at end of file diff --git a/anatomy/tests/test_url_smoke.py b/anatomy/tests/test_url_smoke.py new file mode 100644 index 00000000..5dccddc7 --- /dev/null +++ b/anatomy/tests/test_url_smoke.py @@ -0,0 +1,122 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + # Add to editor and manager groups to satisfy all custom mixins + for group_name in ["anatomy_editor", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from anatomy.models import AnatomyQuestion, Exam, UserAnswer, QuestionType, Structure, Answer + import tempfile + + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + qtype, _ = QuestionType.objects.get_or_create(text="Test QType") + structure, _ = Structure.objects.get_or_create(name="Test Structure") + + image_file = tempfile.NamedTemporaryFile(dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False) + + question = AnatomyQuestion.objects.create( + question_type=qtype, + structure=structure, + image=image_file.name + ) + + exam = Exam.objects.create( + name="Test Anatomy Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + + answer = Answer.objects.create( + question=question, + answer="testanswer", + status=Answer.MarkOptions.CORRECT + ) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question + ) + + return { + "question": question, + "exam": exam, + "user_answer": user_answer, + } + +URL_SPECS = [ + ("anatomy:question_view", None), + ("anatomy:question_create", None), + ("anatomy:anatomy_question_update", lambda d: {"pk": d["question"].pk}), + ("anatomy:question_answer_update", lambda d: {"pk": d["question"].pk}), + ("anatomy:question_clone", lambda d: {"pk": d["question"].pk}), + ("anatomy:question_delete", lambda d: {"pk": d["question"].pk}), + ("anatomy:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("anatomy:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("anatomy:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("anatomy:exam_create", None), + ("anatomy:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("anatomy:exam_update", lambda d: {"pk": d["exam"].pk}), + ("anatomy:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("anatomy:user_answer_table_view", None), + ("anatomy:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("anatomy:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("anatomy:mark2_overview", lambda d: {"pk": d["exam"].pk}), + ("anatomy:index", None), + ("anatomy:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/anatomy/views.py b/anatomy/views.py index 517ea135..9b9d7508 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -104,6 +104,8 @@ from django.db.models import Count class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj if self.request.user.groups.filter(name="anatomy_checker").exists(): return obj if self.request.user not in obj.author.all(): diff --git a/atlas/tests/__init__.py b/atlas/tests/__init__.py new file mode 100644 index 00000000..978024c1 --- /dev/null +++ b/atlas/tests/__init__.py @@ -0,0 +1 @@ +# Atlas tests package diff --git a/generic/models.py b/generic/models.py index e80228c4..5bc1286d 100644 --- a/generic/models.py +++ b/generic/models.py @@ -971,26 +971,21 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin): def clone_model(self): - M2M_fields = ( - "exam_questions", - "author", - "valid_cid_users", - "valid_user_users", - "cid_user_groups", - "user_user_groups", - ) + m2m_fields = [f.name for f in self._meta.many_to_many] FK_fields = ("examcollection",) model_dict = model_to_dict(self) m2m_to_update = {} - for field in M2M_fields: - m2m_to_update[field] = model_dict[field] - del model_dict[field] + for field in m2m_fields: + if field in model_dict: + m2m_to_update[field] = model_dict[field] + del model_dict[field] fk_to_update = {} for field in FK_fields: - fk_to_update[field] = model_dict[field] - del model_dict[field] + if field in model_dict: + fk_to_update[field] = model_dict[field] + del model_dict[field] cloned_model = self.__class__(**model_dict) cloned_model.pk = None @@ -2381,7 +2376,17 @@ class FindingBase(models.Model): return json.dumps(self.annotation_json) def get_pretty_annotation_json(self): - return get_pretty_json(json.loads(self.annotation_json)) + if not self.annotation_json: + return "" + try: + return get_pretty_json(json.loads(self.annotation_json)) + except Exception: + return "" def get_pretty_viewport_json(self): - return get_pretty_json(json.loads(self.viewport_json)) + if not self.viewport_json: + return "" + try: + return get_pretty_json(json.loads(self.viewport_json)) + except Exception: + return "" diff --git a/generic/templates/generic/help.html b/generic/templates/generic/help.html new file mode 100644 index 00000000..0554cc05 --- /dev/null +++ b/generic/templates/generic/help.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

{{ app_name|title }} Help

+

Welcome to the radiology education platform. There is no custom help document for the {{ app_name }} application yet.

+
+ Go Back +
+
+
+{% endblock %} diff --git a/generic/tests/test_url_smoke.py b/generic/tests/test_url_smoke.py new file mode 100644 index 00000000..32846525 --- /dev/null +++ b/generic/tests/test_url_smoke.py @@ -0,0 +1,117 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client +from generic.models import Examination, Supervisor, CidUserGroup, UserUserGroup, CidUser + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def cid_manager_client(db): + user = User.objects.create_user(username="manager", password="password", email="manager@test.com") + g, _ = Group.objects.get_or_create(name="cid_user_manager") + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + g, _ = Group.objects.get_or_create(name="cid_user_manager") + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, create_user, create_examination): + supervisor = Supervisor.objects.create( + email="sup@test.com", + name="Test Supervisor", + user=create_user + ) + cid_group = CidUserGroup.objects.create(name="Cid Group") + user_group = UserUserGroup.objects.create(name="User Group") + cid_user = CidUser.objects.create(cid=99999, name="Test Cid User", email="cid@test.com", passcode="smokepasscode") + + return { + "examination": create_examination, + "supervisor": supervisor, + "cid_group": cid_group, + "user_group": user_group, + "cid_user": cid_user, + } + +URL_SPECS = [ + ("generic:examination_view", None), + ("generic:create_examination", None), + ("generic:examination_detail", lambda d: {"pk": d["examination"].pk}), + ("generic:examination_update", lambda d: {"pk": d["examination"].pk}), + ("generic:examination_delete", lambda d: {"pk": d["examination"].pk}), + ("generic:supervisor", None), + ("generic:supervisor_create", None), + ("generic:supervisor_detail", lambda d: {"pk": d["supervisor"].pk}), + ("generic:supervisor_edit", lambda d: {"pk": d["supervisor"].pk}), + ("generic:supervisor_delete", lambda d: {"pk": d["supervisor"].pk}), + ("generic:supervisor_overview", lambda d: {"pk": d["supervisor"].pk}), + ("generic:manage_cids", None), + ("generic:manage_cid_exams", None), + ("generic:cid_group_view", None), + ("generic:cid_group_create", None), + ("generic:cid_group_detail", lambda d: {"group_id": d["cid_group"].pk}), + ("generic:cid_group_update", lambda d: {"pk": d["cid_group"].pk}), + ("generic:cid_group_delete", lambda d: {"pk": d["cid_group"].pk}), + ("generic:user_group_view", None), + ("generic:user_group_create", None), + ("generic:user_group_detail", lambda d: {"group_id": d["user_group"].pk}), + ("generic:user_group_update", lambda d: {"pk": d["user_group"].pk}), + ("generic:user_group_delete", lambda d: {"pk": d["user_group"].pk}), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_manager(cid_manager_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = cid_manager_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Manager" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Manager" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/generic/views.py b/generic/views.py index 444758dc..c55d86c9 100644 --- a/generic/views.py +++ b/generic/views.py @@ -4085,7 +4085,13 @@ class GenericViewBase: return False def help(self, request): - return render(request, f"{self.app_name}/help.html", {"app_name": self.app_name}) + from django.template.loader import TemplateDoesNotExist, get_template + try: + get_template(f"{self.app_name}/help.html") + template_name = f"{self.app_name}/help.html" + except TemplateDoesNotExist: + template_name = "generic/help.html" + return render(request, template_name, {"app_name": self.app_name}) def question_review(self, request, pk): """ @@ -6456,6 +6462,7 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView): raise PermissionDenied() # or Http404 +@login_required def supervisor_overview(request, pk): supervisor = get_object_or_404(Supervisor, pk=pk) @@ -6463,7 +6470,7 @@ def supervisor_overview(request, pk): try: if not request.user.supervisor == supervisor: raise PermissionDenied() - except Supervisor.RelatedObjectDoesNotExist: + except User.supervisor.RelatedObjectDoesNotExist: raise PermissionDenied() trainees = User.objects.filter(userprofile__supervisor=supervisor) @@ -6475,6 +6482,7 @@ def supervisor_overview(request, pk): ) +@login_required def supervisor_trainee(request, pk, trainee_id): supervisor = get_object_or_404(Supervisor, pk=pk) @@ -6482,7 +6490,7 @@ def supervisor_trainee(request, pk, trainee_id): try: if not request.user.supervisor == supervisor: raise PermissionDenied() - except Supervisor.RelatedObjectDoesNotExist: + except User.supervisor.RelatedObjectDoesNotExist: raise PermissionDenied() # We do this to check that the supervisor can (should) see the trainee results diff --git a/generic/widgets.py b/generic/widgets.py index 10c682f1..d9ca3bfe 100644 --- a/generic/widgets.py +++ b/generic/widgets.py @@ -21,7 +21,20 @@ class UserSearchSelectMultipleWidget: def __init__(self, name, value): self.name = name - self.value = value or [] + if value is None: + self.value = [] + elif hasattr(value, "values_list"): + self.value = list(value.values_list("pk", flat=True)) + elif isinstance(value, (list, tuple, set)): + self.value = [v.pk if hasattr(v, "pk") else v for v in value] + elif hasattr(value, "pk"): + self.value = [value.pk] + elif isinstance(value, (int, str)): + self.value = [value] + elif hasattr(value, "__iter__") and not isinstance(value, (str, bytes)): + self.value = [v.pk if hasattr(v, "pk") else v for v in value] + else: + self.value = [value] def render(self): field_id = f"id_{self.name}" diff --git a/longs/filters.py b/longs/filters.py index a93cc853..b0246010 100755 --- a/longs/filters.py +++ b/longs/filters.py @@ -67,7 +67,8 @@ class UserAnswerFilter(django_filters.FilterSet): ) def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): - if not request.user.groups.filter(name="long_checker").exists(): - queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id) + if not request.user.is_superuser: + if not request.user.groups.filter(name="long_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id) super(UserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) pass \ No newline at end of file diff --git a/longs/tests.py b/longs/tests.py deleted file mode 100755 index 7ce503c2..00000000 --- a/longs/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/longs/tests/__init__.py b/longs/tests/__init__.py new file mode 100644 index 00000000..b67e1bb2 --- /dev/null +++ b/longs/tests/__init__.py @@ -0,0 +1 @@ +# Package marker for tests diff --git a/longs/tests/test_url_smoke.py b/longs/tests/test_url_smoke.py new file mode 100644 index 00000000..49f9ce40 --- /dev/null +++ b/longs/tests/test_url_smoke.py @@ -0,0 +1,163 @@ +import pytest +import tempfile +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + for group_name in ["longs_checker", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from longs.models import Long, LongSeries, LongSeriesImage, Exam, UserAnswer, Examination + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + exm_name, _ = Examination.objects.get_or_create(examination="Test Examination") + + question = Long.objects.create( + description="Test description", + history="Test History", + model_observations="Test Observations", + model_interpretation="Test Interpretation", + model_principle_diagnosis="Test Diagnosis", + model_differential_diagnosis="Test Differentials", + model_management="Test Management", + ) + + series = LongSeries.objects.create( + description="Test Series", + examination=exm_name, + ) + question.series.add(series) + + # Mocking series image + image = tempfile.NamedTemporaryFile( + dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False + ) + image.flush() + image._committed = True + image.write(b"12345ABCD") + image.seek(0) + series_image = LongSeriesImage(image=image.name, series=series) + series_image.save() + + exam = Exam.objects.create( + name="Test Longs Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + + from longs.models import ExamQuestionDetail + ExamQuestionDetail.objects.create(exam=exam, question=question, sort_order=1) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question, + answer_observations="testanswer", + answer_interpretation="testanswer", + answer_principle_diagnosis="testanswer", + answer_differential_diagnosis="testanswer", + answer_management="testanswer", + ) + + return { + "question": question, + "series": series, + "exam": exam, + "user_answer": user_answer, + } + +URL_SPECS = [ + ("longs:author_list", None), + ("longs:question_view", None), + ("longs:series_view", None), + ("longs:series_detail", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_order_dicom", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_download", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_order_dicom_instance", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_order_dicom_SeriesInstanceUID", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_order_upload_filename", lambda d: {"pk": d["series"].pk}), + ("longs:long_series_delete", lambda d: {"pk": d["series"].pk}), + ("longs:question_detail", lambda d: {"pk": d["question"].pk}), + ("longs:question_json", lambda d: {"pk": d["question"].pk}), + ("longs:question_json_unbased", lambda d: {"pk": d["question"].pk}), + ("longs:question_json_recreate", lambda d: {"pk": d["question"].pk}), + ("longs:long_split", lambda d: {"pk": d["question"].pk}), + ("longs:long_clone", lambda d: {"pk": d["question"].pk}), + ("longs:long_scrap", lambda d: {"pk": d["question"].pk}), + ("longs:long_delete", lambda d: {"pk": d["question"].pk}), + ("longs:mark_answer", lambda d: {"exam_id": d["exam"].pk, "question_number": 0, "answer_id": d["user_answer"].pk}), + ("longs:mark_answer_override", lambda d: {"exam_id": d["exam"].pk, "question_number": 0, "answer_id": d["user_answer"].pk}), + ("longs:mark", lambda d: {"exam_id": d["exam"].pk, "sk": 0}), + ("longs:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("longs:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("longs:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("longs:refresh_exam_question_json", lambda d: {"pk": d["exam"].pk}), + ("longs:exam_create", None), + ("longs:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("longs:exam_update", lambda d: {"pk": d["exam"].pk}), + ("longs:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("longs:question_create", None), + ("longs:series_create", None), + ("longs:long_series_id_create", lambda d: {"pk": d["question"].pk}), + ("longs:create_examination", None), + ("longs:long_update", lambda d: {"pk": d["question"].pk}), + ("longs:long_series_update", lambda d: {"pk": d["series"].pk}), + ("longs:user_answer_table_view", None), + ("longs:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("longs:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("longs:user_answer_delete_multiple", None), + ("longs:index", None), + ("longs:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES or response.status_code == 400, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/longs/views.py b/longs/views.py index 86571649..024d4787 100755 --- a/longs/views.py +++ b/longs/views.py @@ -99,7 +99,7 @@ from zipview.views import BaseZipView import os -logger = logging.getLogger(__name__) + def normaliseScore(score): @@ -176,46 +176,14 @@ def series_detail(request, pk): @login_required @user_is_author_or_long_checker def long_split(request, pk): - long = get_object_or_404(Long, pk=pk) - - images = long.images.all() - - old_abnormality = long.abnormality.all() - old_region = long.region.all() - old_examination = long.examination.all() - old_site = long.site.all() - old_author = long.author.all() - - if not images: - raise Http404 - - for n in range(len(images) - 1): - long.pk = None - - long.save() - - long.abnormality.set(old_abnormality) - long.region.set(old_region) - long.examination.set(old_examination) - long.site.set(old_site) - long.author.set(old_author) - images[n].long = long - images[n].save() - - # images[-1].long - - # if request.user not in long.author.all(): - # raise PermissionDenied - - # logging.debug(long.subspecialty.first().name.all()) - return render(request, "longs/question_detail.html", {"question": long}) + raise Http404("Long cases do not support direct image splitting.") @login_required -@user_is_author_or_long_checker def author_detail(request, pk): # logging.debug(Author.objects.all()) - # author = get_object_or_404(Author, pk=pk) + if not (request.user.pk == pk or request.user.groups.filter(name='long_checker').exists() or request.user.is_superuser): + raise PermissionDenied author = User.objects.get(pk=pk) longs = Long.objects.filter(author=pk) @@ -225,7 +193,7 @@ def author_detail(request, pk): ) -@user_is_author_or_long_checker +@user_is_long_checker def author_list(request): authors = User.objects.all() @@ -649,8 +617,8 @@ class LongSeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): # @user_is_long_marker -def mark_answer_override(request, exam_id, question_number, cid): - return mark_answer(request, exam_id, question_number, cid, override=True) +def mark_answer_override(request, exam_id, question_number, answer_id): + return mark_answer(request, exam_id, question_number, answer_id, override=True) # @user_is_long_marker @@ -1075,14 +1043,14 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): @user_passes_test(lambda u: u.is_superuser) def user_answer_delete_multiple(request): - if request.accepts("application/json")(): + if request.accepts("application/json") and request.method == "POST" and "answer_ids" in request.POST: answer_ids = json.loads(request.POST["answer_ids"]) # We could probably delete them all at once.... for id in answer_ids: UserAnswer.objects.get(pk=id).delete() return JsonResponse({"status": "success"}) - return JsonResponse({"status": "error"}) + return JsonResponse({"status": "error"}, status=400) @user_passes_test(lambda u: u.is_superuser) diff --git a/media_test/tmpam34uuxi.jpg b/media_test/tmpam34uuxi.jpg new file mode 100644 index 00000000..e69de29b diff --git a/media_test/tmpn6geswcc.jpg b/media_test/tmpn6geswcc.jpg new file mode 100644 index 00000000..e69de29b diff --git a/media_test/tmpqmjkbq5c.jpg b/media_test/tmpqmjkbq5c.jpg new file mode 100644 index 00000000..e69de29b diff --git a/media_testanatomy/exam/1.json b/media_testanatomy/exam/1.json new file mode 100644 index 00000000..cd65ff91 --- /dev/null +++ b/media_testanatomy/exam/1.json @@ -0,0 +1 @@ +{"eid": "anatomy/1", "cached": false, "exam_type": "anatomy", "exam_name": "Cid Exam", "exam_mode": true, "exam_order": [1, 2, 3], "questions": {"1": {"title": "", "question": "test question type", "images": ["data:image/image/jpeg;base64,"], "annotations": [], "type": "anatomy"}, "2": {"title": "", "question": "test question type", "images": ["data:image/image/jpeg;base64,"], "annotations": [], "type": "anatomy"}, "3": {"title": "", "question": "test question type", "images": ["data:image/image/jpeg;base64,"], "annotations": [], "type": "anatomy"}}, "exam_time": 5400, "generated": "2026-06-10T21:01:41.521878+00:00", "exam_json_id": 2} \ No newline at end of file diff --git a/oef/templates/oef/formats_update_form.html b/oef/templates/oef/formats_update_form.html index 73695ac2..16994d83 100644 --- a/oef/templates/oef/formats_update_form.html +++ b/oef/templates/oef/formats_update_form.html @@ -6,7 +6,8 @@ -{% if request.user.is_superuser %} +{% if object %} +{% if request.user.is_superuser and object.id %} Admin {% endif %} @@ -22,6 +23,8 @@ {% endif %} ">{{ entry.exam_code }} / {{ entry.exam_name }} {% endfor %} + +{% endif %} {% endblock content %} diff --git a/oef/tests.py b/oef/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/oef/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/oef/tests/__init__.py b/oef/tests/__init__.py new file mode 100644 index 00000000..41113998 --- /dev/null +++ b/oef/tests/__init__.py @@ -0,0 +1 @@ +# OEF tests package diff --git a/oef/tests/test_url_smoke.py b/oef/tests/test_url_smoke.py new file mode 100644 index 00000000..d9ef1f03 --- /dev/null +++ b/oef/tests/test_url_smoke.py @@ -0,0 +1,95 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 400, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db): + from oef.models import Entry, Formats + entry = Entry.objects.create( + modality="CT", + exam_code="CT1", + exam_name="CT Entry", + questions="Question Text\nQuestion 2", + questions_original="Question Text\nQuestion 2", + ) + fmt = Formats.objects.create( + name="Format 1", + format="Question Text", + ) + return { + "entry": entry, + "format": fmt, + } + +URL_SPECS = [ + ("oef:entry_table_view", None), + ("oef:formats_view", None), + ("oef:formats_dupe_view", None), + ("oef:format_create", None), + ("oef:format_search", None), + ("oef:replace_questions", None), + ("oef:questions", None), + ("oef:subspecialties", None), + ("oef:entries_table", None), + ("oef:entries", None), + ("oef:questions_diff", None), + ("oef:questions_sym", None), + ("oef:entries_by_question", None), + ("oef:entries_by_question_codes", None), + ("oef:entry_update", lambda d: {"pk": d["entry"].pk}), + ("oef:entry_detail", lambda d: {"pk": d["entry"].pk}), + ("oef:entry_detail_formats", lambda d: {"pk": d["entry"].pk}), + ("oef:format_update", lambda d: {"pk": d["format"].pk}), + ("oef:formats_apply", lambda d: {"pk": d["format"].pk}), + ("oef:formats_delete", lambda d: {"pk": d["format"].pk}), + ("oef:formats_save", None), + ("oef:output", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/physics/filters.py b/physics/filters.py index affa030f..42517305 100644 --- a/physics/filters.py +++ b/physics/filters.py @@ -58,7 +58,8 @@ class UserAnswerFilter(django_filters.FilterSet): ) def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): - if not request.user.groups.filter(name="physics_checker").exists(): - queryset = queryset.filter(author__id=request.user.id) + if not request.user.is_superuser: + if not request.user.groups.filter(name="physics_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id) super(UserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) pass \ No newline at end of file diff --git a/physics/tests.py b/physics/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/physics/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/physics/tests/__init__.py b/physics/tests/__init__.py new file mode 100644 index 00000000..b67e1bb2 --- /dev/null +++ b/physics/tests/__init__.py @@ -0,0 +1 @@ +# Package marker for tests diff --git a/physics/tests/test_url_smoke.py b/physics/tests/test_url_smoke.py new file mode 100644 index 00000000..4b54becf --- /dev/null +++ b/physics/tests/test_url_smoke.py @@ -0,0 +1,132 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + # Add to editor and manager groups to satisfy all custom mixins + for group_name in ["physics_checker", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from physics.models import Question, Exam, UserAnswer, Category + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + category, _ = Category.objects.get_or_create(category="Test Category") + + question = Question.objects.create( + stem="test question stem", + a="question a text", + a_feedback="question a feedback", + b="question b text", + b_feedback="question b feedback", + c="question c text", + c_feedback="question c feedback", + d="question d text", + d_feedback="question d feedback", + e="question e text", + e_feedback="question e feedback", + a_answer=True, + b_answer=False, + c_answer=True, + d_answer=False, + e_answer=True, + category=category, + ) + + exam = Exam.objects.create( + name="Test Physics Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + # Link question to exam + from physics.models import ExamQuestionDetail + ExamQuestionDetail.objects.create(exam=exam, question=question, sort_order=1) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question, + a=True, + b=False, + c=True, + d=False, + e=True, + ) + + return { + "question": question, + "exam": exam, + "user_answer": user_answer, + } + +URL_SPECS = [ + ("physics:question_view", None), + ("physics:question_create", None), + ("physics:question_create_exam", lambda d: {"pk": d["exam"].pk}), + ("physics:question_detail", lambda d: {"pk": d["question"].pk}), + ("physics:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("physics:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("physics:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("physics:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("physics:exam_update", lambda d: {"pk": d["exam"].pk}), + ("physics:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("physics:active_exams", None), + ("physics:user_answer_table_view", None), + ("physics:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("physics:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("physics:exam_review_question_responses", lambda d: {"pk": d["exam"].pk, "q_index": 0}), + ("physics:exam_review_question_summary", lambda d: {"pk": d["exam"].pk, "q_index": 0}), + ("physics:index", None), + ("physics:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/physics/views.py b/physics/views.py index ef1e59ec..ba3a5ab7 100644 --- a/physics/views.py +++ b/physics/views.py @@ -75,10 +75,18 @@ from loguru import logger class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj if self.request.user.groups.filter(name="physics_checker").exists(): return obj - if self.request.user not in obj.author.all(): - raise PermissionDenied() # or Http404 + if hasattr(obj, "author"): + if self.request.user not in obj.author.all(): + raise PermissionDenied() + elif hasattr(obj, "user"): + if self.request.user != obj.user: + raise PermissionDenied() + else: + raise PermissionDenied() return obj diff --git a/rad/settings_test.py b/rad/settings_test.py index a200d53f..605aa94f 100644 --- a/rad/settings_test.py +++ b/rad/settings_test.py @@ -31,3 +31,8 @@ TASKS = { "QUEUES": ["default"], } } + +# Local media root for tests to avoid container-only paths in settings_local +MEDIA_ROOT = os.path.join(BASE_DIR, "media_test") +os.makedirs(MEDIA_ROOT, exist_ok=True) + diff --git a/rapids/filters.py b/rapids/filters.py index c8e9fdd6..66cf8bb1 100755 --- a/rapids/filters.py +++ b/rapids/filters.py @@ -91,10 +91,11 @@ class RapidUserAnswerFilter(django_filters.FilterSet): user=None, request=None, ): - if not request.user.groups.filter(name="rapid_checker").exists(): - queryset = queryset.filter(open_access=True) | queryset.filter( - author__id=request.user.id - ) + if not request.user.is_superuser: + if not request.user.groups.filter(name="rapid_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter( + exam__author__id=request.user.id + ) super(RapidUserAnswerFilter, self).__init__( data=data, queryset=queryset, prefix=prefix, request=request ) diff --git a/rapids/tests/__init__.py b/rapids/tests/__init__.py new file mode 100644 index 00000000..b67e1bb2 --- /dev/null +++ b/rapids/tests/__init__.py @@ -0,0 +1 @@ +# Package marker for tests diff --git a/rapids/tests/test_url_smoke.py b/rapids/tests/test_url_smoke.py new file mode 100644 index 00000000..ad31a142 --- /dev/null +++ b/rapids/tests/test_url_smoke.py @@ -0,0 +1,163 @@ +import pytest +import tempfile +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + # Add to editor/checker/manager groups to satisfy all custom mixins + for group_name in ["rapid_checker", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from rapids.models import Rapid, RapidImage, Exam, UserAnswer, Answer, Abnormality, Region, Examination + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + # Create dummy supporting model objects + abn, _ = Abnormality.objects.get_or_create(name="Test Abnormality") + reg, _ = Region.objects.get_or_create(name="Test Region") + exm_name, _ = Examination.objects.get_or_create(examination="Test Examination") + + question = Rapid.objects.create( + laterality=Rapid.NONE, + history="Test History", + normal=False, + ) + question.abnormality.add(abn) + question.region.add(reg) + question.examination.add(exm_name) + + # Mocking image + image = tempfile.NamedTemporaryFile( + dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False + ) + image.flush() + image._committed = True + # Save dummy content to prevent issues with PIL/DICOM hashing + image.write(b"12345ABCD") + image.seek(0) + rapid_image = RapidImage(image=image.name, rapid=question) + rapid_image.save() + + answer = Answer.objects.create( + question=question, + answer="testanswer", + answer_compare="testanswer", + status=Answer.MarkOptions.CORRECT, + ) + + exam = Exam.objects.create( + name="Test Rapids Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + # Link question to exam + from rapids.models import ExamQuestionDetail + ExamQuestionDetail.objects.create(exam=exam, rapid=question, sort_order=1) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question, + normal=False, + answer="testanswer", + answer_compare="testanswer", + score=Answer.MarkOptions.CORRECT, + ) + + return { + "question": question, + "exam": exam, + "user_answer": user_answer, + "answer": answer, + } + +URL_SPECS = [ + ("rapids:question_viewer", None), + ("rapids:question_view", None), + ("rapids:question_json", lambda d: {"pk": d["question"].pk}), + ("rapids:question_json_unbased", lambda d: {"pk": d["question"].pk}), + ("rapids:question_save_annotation", lambda d: {"pk": d["question"].pk}), + ("rapids:rapid_split", lambda d: {"pk": d["question"].pk}), + ("rapids:question_migrate_to_shorts", lambda d: {"pk": d["question"].pk}), + ("rapids:rapid_clone", lambda d: {"pk": d["question"].pk}), + ("rapids:rapid_scrap", lambda d: {"pk": d["question"].pk}), + ("rapids:question_delete", lambda d: {"pk": d["question"].pk}), + ("rapids:question_anonymise_dicom", lambda d: {"pk": d["question"].pk}), + ("rapids:question_add_exam", lambda d: {"question_id": d["question"].pk}), + ("rapids:confirm_answer", lambda d: {"answer_id": d["answer"].pk}), + ("rapids:delete_answer", lambda d: {"answer_id": d["answer"].pk}), + ("rapids:mark", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("rapids:mark_all", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("rapids:mark_review", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("rapids:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("rapids:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("rapids:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("rapids:exam_create", None), + ("rapids:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("rapids:exam_update", lambda d: {"pk": d["exam"].pk}), + ("rapids:exam_migrate_rapids_to_shorts", lambda d: {"pk": d["exam"].pk}), + ("rapids:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("rapids:question_create", None), + ("rapids:question_create_exam", lambda d: {"pk": d["exam"].pk}), + ("rapids:create_region", None), + ("rapids:create_abnormality", None), + ("rapids:create_examination", None), + ("rapids:rapid_update", lambda d: {"pk": d["question"].pk}), + ("rapids:question_answer_update", lambda d: {"pk": d["question"].pk}), + ("rapids:user_answer_table_view", None), + ("rapids:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("rapids:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("rapids:index", None), + ("rapids:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES or response.status_code == 400, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303, 400, 403, 404}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/rapids/views.py b/rapids/views.py index 112a8797..9613ad3e 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -115,6 +115,8 @@ def normaliseScore(score): class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj if self.request.user.groups.filter(name="rapid_checker").exists(): return obj if self.request.user not in obj.author.all(): @@ -950,7 +952,8 @@ def question_json(request, pk): def question_viewer(request): # question = get_object_or_404(Rapid, pk=pk) - ids = request.GET.get("ids").split(",") + ids_param = request.GET.get("ids") + ids = ids_param.split(",") if ids_param else [] return render( request, diff --git a/rcr/tests.py b/rcr/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/rcr/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/rcr/tests/__init__.py b/rcr/tests/__init__.py new file mode 100644 index 00000000..a1d258fa --- /dev/null +++ b/rcr/tests/__init__.py @@ -0,0 +1 @@ +# RCR tests package diff --git a/rcr/tests/conftest.py b/rcr/tests/conftest.py index 41d83f8b..d18a12fe 100644 --- a/rcr/tests/conftest.py +++ b/rcr/tests/conftest.py @@ -21,9 +21,9 @@ def create_cid_manager(db, django_user_model): @pytest.fixture def create_groups(db): - Group.objects.create(name="rcr_radiology_assessor") - Group.objects.create(name="rcr_oncology_assessor") - Group.objects.create(name="rcr_assessor") + Group.objects.get_or_create(name="rcr_radiology_assessor") + Group.objects.get_or_create(name="rcr_oncology_assessor") + Group.objects.get_or_create(name="rcr_assessor") @pytest.fixture diff --git a/rcr/tests/test_url_smoke.py b/rcr/tests/test_url_smoke.py new file mode 100644 index 00000000..40b7bbe1 --- /dev/null +++ b/rcr/tests/test_url_smoke.py @@ -0,0 +1,97 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 400, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def assessor_client(db): + user = User.objects.create_user(username="assessor", password="password", email="assessor@test.com") + for group_name in ["rcr_radiology_assessor", "rcr_oncology_assessor", "rcr_assessor"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + for group_name in ["rcr_radiology_assessor", "rcr_oncology_assessor", "rcr_assessor", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, create_item, create_radiology_user): + return { + "item": create_item, + "user": create_radiology_user, + } + +URL_SPECS = [ + ("rcr:radiology_index_view", None), + ("rcr:radiology_index_all_view", None), + ("rcr:radiology_gap", None), + ("rcr:radiology_results", None), + ("rcr:radiology_review_view", None), + ("rcr:radiology_index_completed_view", None), + ("rcr:radiology_assessors_view", None), + ("rcr:radiology_assigned_user_view", lambda d: {"user_pk": d["user"].pk}), + ("rcr:radiology_assessor_assignment_view", lambda d: {"assessor_pk": d["user"].pk}), + ("rcr:radiology_index_completed_by_user_view", lambda d: {"user_pk": d["user"].pk}), + ("rcr:radiology_detail_view", lambda d: {"pk": d["item"].pk}), + ("rcr:radiology_update_next_view", None), + ("rcr:radiology_update_next_skip_view", lambda d: {"skip": 0}), + ("rcr:radiology_update_view", lambda d: {"pk": d["item"].pk}), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_assessor(assessor_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = assessor_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Assessor" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Assessor" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/rota/templates/rota/userrotadetail_form.html b/rota/templates/rota/userrotadetail_form.html new file mode 100644 index 00000000..9544f656 --- /dev/null +++ b/rota/templates/rota/userrotadetail_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block content %} +
+

Update Rota Details

+
+ {% csrf_token %} + {{ form|crispy }} + +
+
+{% block content_extra %}{% endblock %} +{% endblock %} diff --git a/rota/tests.py b/rota/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/rota/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/rota/tests/__init__.py b/rota/tests/__init__.py new file mode 100644 index 00000000..54d1617b --- /dev/null +++ b/rota/tests/__init__.py @@ -0,0 +1 @@ +# Rota tests package diff --git a/rota/tests/test_url_smoke.py b/rota/tests/test_url_smoke.py new file mode 100644 index 00000000..15e46796 --- /dev/null +++ b/rota/tests/test_url_smoke.py @@ -0,0 +1,65 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 400, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db): + from rota.models import UserRotaDetail + user = User.objects.create_user(username="smoke_user", password="password", email="smoke@test.com") + detail = UserRotaDetail.objects.create(user=user) + return { + "user": user, + "detail": detail, + } + +URL_SPECS = [ + ("rota:user_details_update", lambda d: {"pk": d["detail"].pk}), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in SAFE_STATUS_CODES, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/sbas/filters.py b/sbas/filters.py index 580346d5..3c39c280 100644 --- a/sbas/filters.py +++ b/sbas/filters.py @@ -106,7 +106,8 @@ class UserAnswerFilter(django_filters.FilterSet): ) def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): - if not request.user.groups.filter(name="sba_checker").exists(): - queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id) + if not request.user.is_superuser: + if not request.user.groups.filter(name="sba_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter(exam__author__id=request.user.id) super(UserAnswerFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) pass \ No newline at end of file diff --git a/sbas/templates/sbas/question_confirm_delete.html b/sbas/templates/sbas/question_confirm_delete.html new file mode 100644 index 00000000..5120595b --- /dev/null +++ b/sbas/templates/sbas/question_confirm_delete.html @@ -0,0 +1,7 @@ +{% extends 'sbas/base.html' %} +{% block content %} +
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ +
+{% endblock %} diff --git a/sbas/tests.py b/sbas/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/sbas/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/sbas/tests/__init__.py b/sbas/tests/__init__.py new file mode 100644 index 00000000..b67e1bb2 --- /dev/null +++ b/sbas/tests/__init__.py @@ -0,0 +1 @@ +# Package marker for tests diff --git a/sbas/tests/test_url_smoke.py b/sbas/tests/test_url_smoke.py new file mode 100644 index 00000000..7ef7470b --- /dev/null +++ b/sbas/tests/test_url_smoke.py @@ -0,0 +1,131 @@ +import pytest +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + for group_name in ["sba_checker", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from sbas.models import Question, Exam, UserAnswer, Category + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + cat, _ = Category.objects.get_or_create(category="Test Category") + + question = Question.objects.create( + stem="Test Stem", + a_answer="Ans A", + b_answer="Ans B", + c_answer="Ans C", + d_answer="Ans D", + e_answer="Ans E", + best_answer="a", + category=cat, + ) + + exam = Exam.objects.create( + name="Test SBA Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + + from sbas.models import ExamQuestionDetail + ExamQuestionDetail.objects.create(exam=exam, question=question, sort_order=1) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question, + answer="a", + ) + + return { + "question": question, + "exam": exam, + "user_answer": user_answer, + } + +URL_SPECS = [ + ("sbas:question_view", None), + ("sbas:question_create", None), + ("sbas:question_update", lambda d: {"pk": d["question"].pk}), + ("sbas:question_clone", lambda d: {"pk": d["question"].pk}), + ("sbas:question_delete", lambda d: {"pk": d["question"].pk}), + ("sbas:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("sbas:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("sbas:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("sbas:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("sbas:exam_clone2", lambda d: {"exam_id": d["exam"].pk}), + ("sbas:active_exams", None), + ("sbas:exam_create", None), + ("sbas:exam_update", lambda d: {"pk": d["exam"].pk}), + ("sbas:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("sbas:user_answer_table_view", None), + ("sbas:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("sbas:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("sbas:llm_prompt_view", None), + ("sbas:import_llm_questions", None), + ("sbas:import_llm_confirm", None), + ("sbas:exam_create_from_filters_with_name", None), + ("sbas:generate_exam", None), + ("sbas:question_overview", None), + ("sbas:question_search_page", None), + ("sbas:question_search_partial", None), + ("sbas:exam_review_question_responses", lambda d: {"pk": d["exam"].pk, "q_index": 0}), + ("sbas:exam_review_question_summary", lambda d: {"pk": d["exam"].pk, "q_index": 0}), + ("sbas:toggle_frcr", lambda d: {"pk": d["question"].pk}), + ("sbas:merge_category", None), + ("sbas:index", None), + ("sbas:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES or response.status_code == 400, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/sbas/views.py b/sbas/views.py index 037f9d05..84547b99 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -207,6 +207,8 @@ def exam_review_question_summary(request, pk: int, q_index: int): class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj if self.request.user.groups.filter(name="sbas_checker").exists(): return obj if self.request.user not in obj.author.all(): diff --git a/shorts/filters.py b/shorts/filters.py index 8d1538f6..133100d5 100755 --- a/shorts/filters.py +++ b/shorts/filters.py @@ -86,10 +86,11 @@ class QuestionUserAnswerFilter(django_filters.FilterSet): user=None, request=None, ): - if not request.user.groups.filter(name="shorts_checker").exists(): - queryset = queryset.filter(open_access=True) | queryset.filter( - author__id=request.user.id - ) + if not request.user.is_superuser: + if not request.user.groups.filter(name="shorts_checker").exists(): + queryset = queryset.filter(exam__open_access=True) | queryset.filter( + exam__author__id=request.user.id + ) super(QuestionUserAnswerFilter, self).__init__( data=data, queryset=queryset, prefix=prefix, request=request ) diff --git a/shorts/templates/shorts/question_confirm_delete.html b/shorts/templates/shorts/question_confirm_delete.html new file mode 100644 index 00000000..5e92fd70 --- /dev/null +++ b/shorts/templates/shorts/question_confirm_delete.html @@ -0,0 +1,7 @@ +{% extends 'shorts/base.html' %} +{% block content %} +
{% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ +
+{% endblock %} diff --git a/shorts/templates/shorts/question_viewer.html b/shorts/templates/shorts/question_viewer.html new file mode 100644 index 00000000..c6e63059 --- /dev/null +++ b/shorts/templates/shorts/question_viewer.html @@ -0,0 +1,8 @@ +
+
+ + + +{% for id in ids %} +{{id}} +{% endfor %} diff --git a/shorts/templates/shorts/user_answer_question_view.html b/shorts/templates/shorts/user_answer_question_view.html new file mode 100644 index 00000000..e0fbe01e --- /dev/null +++ b/shorts/templates/shorts/user_answer_question_view.html @@ -0,0 +1,31 @@ +{% extends 'shorts/base.html' %} + +{% load render_table from django_tables2 %} +{% block css %} +{% endblock %} + +{% block content %} + +
+

Shorts User Answers

+
+ {{ filter.form }} + +
+
+{% render_table table %} + + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/shorts/templates/shorts/useranswer_detail.html b/shorts/templates/shorts/useranswer_detail.html new file mode 100644 index 00000000..560b57bf --- /dev/null +++ b/shorts/templates/shorts/useranswer_detail.html @@ -0,0 +1,12 @@ +{% extends 'shorts/base.html' %} + +{% block content %} + CID: {{useranswer.cid}}
+ User: {{useranswer.user}}
+ Exam: {{useranswer.exam}}
+ Question: {{useranswer.question}}
+ Answer:
{{useranswer.answer}}
+ Score: {{useranswer.get_answer_score}}
+ Delete answer + Admin Edit +{% endblock content %} diff --git a/shorts/tests.py b/shorts/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/shorts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/shorts/tests/__init__.py b/shorts/tests/__init__.py new file mode 100644 index 00000000..b67e1bb2 --- /dev/null +++ b/shorts/tests/__init__.py @@ -0,0 +1 @@ +# Package marker for tests diff --git a/shorts/tests/test_url_smoke.py b/shorts/tests/test_url_smoke.py new file mode 100644 index 00000000..6c5d6d25 --- /dev/null +++ b/shorts/tests/test_url_smoke.py @@ -0,0 +1,151 @@ +import pytest +import tempfile +from django.urls import reverse +from django.contrib.auth.models import Group, User +from django.test import Client + +SAFE_STATUS_CODES = {200, 301, 302, 303, 403, 404, 405} + +@pytest.fixture +def candidate_client(db): + user = User.objects.create_user(username="candidate", password="password", email="candidate@test.com") + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def superuser_client(db): + user = User.objects.create_superuser(username="superuser", password="password", email="superuser@test.com") + for group_name in ["shorts_checker", "cid_user_manager"]: + g, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(g) + client = Client() + client.force_login(user) + return client + +@pytest.fixture +def anonymous_client(db): + return Client() + +@pytest.fixture +def smoke_data(db, settings, tmp_path): + from shorts.models import Question, QuestionImage, Exam, UserAnswer, QuestionFinding + from rapids.models import Examination + settings.MEDIA_ROOT = str(tmp_path) + + create_user = User.objects.create_user(username="smoke_user", email="smoke_user@test.com") + + exm_name, _ = Examination.objects.get_or_create(examination="Test Examination") + + question = Question.objects.create( + history="Test History", + marking_guidance="Test Guidance", + ) + question.examination.add(exm_name) + + # Mocking image + image = tempfile.NamedTemporaryFile( + dir=settings.MEDIA_ROOT, suffix=".jpg", delete=False + ) + image.flush() + image._committed = True + image.write(b"12345ABCD") + image.seek(0) + q_image = QuestionImage(image=image.name, question=question) + q_image.save() + + exam = Exam.objects.create( + name="Test Shorts Exam", + exam_mode=True, + active=True + ) + exam.author.add(create_user) + + from shorts.models import ExamQuestionDetail + ExamQuestionDetail.objects.create(exam=exam, question=question, sort_order=1) + + user_answer = UserAnswer.objects.create( + user=create_user, + exam=exam, + question=question, + answer="testanswer", + score=2, + ) + + finding = QuestionFinding.objects.create( + question=question, + description="Test Finding", + ) + + return { + "question": question, + "exam": exam, + "user_answer": user_answer, + "finding": finding, + } + +URL_SPECS = [ + ("shorts:question_viewer", None), + ("shorts:question_view", None), + ("shorts:question_json", lambda d: {"pk": d["question"].pk}), + ("shorts:question_json_unbased", lambda d: {"pk": d["question"].pk}), + ("shorts:question_save_annotation", lambda d: {"pk": d["question"].pk}), + ("shorts:question_split", lambda d: {"pk": d["question"].pk}), + ("shorts:question_clone", lambda d: {"pk": d["question"].pk}), + ("shorts:question_delete", lambda d: {"pk": d["question"].pk}), + ("shorts:question_anonymise_dicom", lambda d: {"pk": d["question"].pk}), + ("shorts:question_add_exam", lambda d: {"question_id": d["question"].pk}), + ("shorts:combine_images_side_by_side", lambda d: {"question_id": d["question"].pk}), + ("shorts:mark_answer", lambda d: {"exam_id": d["exam"].pk, "question_number": 0, "answer_id": d["user_answer"].pk}), + ("shorts:mark_answer_override", lambda d: {"exam_id": d["exam"].pk, "question_number": 0, "answer_id": d["user_answer"].pk}), + ("shorts:mark", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("shorts:mark_all", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("shorts:mark_review", lambda d: {"exam_pk": d["exam"].pk, "sk": 0}), + ("shorts:exam_markers", lambda d: {"pk": d["exam"].pk}), + ("shorts:exam_groups_edit", lambda d: {"pk": d["exam"].pk}), + ("shorts:exam_authors", lambda d: {"pk": d["exam"].pk}), + ("shorts:exam_create", None), + ("shorts:exam_clone", lambda d: {"exam_id": d["exam"].pk}), + ("shorts:exam_update", lambda d: {"pk": d["exam"].pk}), + ("shorts:exam_delete", lambda d: {"pk": d["exam"].pk}), + ("shorts:question_create", None), + ("shorts:question_create_exam", lambda d: {"pk": d["exam"].pk}), + ("shorts:question_update", lambda d: {"pk": d["question"].pk}), + ("shorts:question_sample_answers", lambda d: {"pk": d["question"].pk}), + ("shorts:user_answer_table_view", None), + ("shorts:user_answer_view", lambda d: {"pk": d["user_answer"].pk}), + ("shorts:user_answer_delete", lambda d: {"pk": d["user_answer"].pk}), + ("shorts:question_findings", lambda d: {"question_id": d["question"].pk}), + ("shorts:add_finding", None), + ("shorts:delete_finding", lambda d: {"pk": d["finding"].pk}), + ("shorts:question_edit_finding", lambda d: {"question_id": d["question"].pk, "finding_pk": d["finding"].pk}), + ("shorts:index", None), + ("shorts:help", None), +] + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_anonymous(anonymous_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = anonymous_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Anonymous" + assert response.status_code in {200, 302, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Anonymous" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_candidate(candidate_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = candidate_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Candidate" + assert response.status_code in SAFE_STATUS_CODES or response.status_code == 400, f"Unexpected status code {response.status_code} on {url_name} for Candidate" + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name, kwargs_lambda", URL_SPECS) +def test_url_smoke_superuser(superuser_client, smoke_data, url_name, kwargs_lambda): + kwargs = kwargs_lambda(smoke_data) if kwargs_lambda else None + url = reverse(url_name, kwargs=kwargs) + response = superuser_client.get(url, follow=False) + assert response.status_code != 500, f"500 Server Error on {url_name} ({url}) for Superuser" + assert response.status_code in {200, 301, 302, 303, 400, 403, 404, 405}, f"Unexpected status code {response.status_code} on {url_name} for Superuser" diff --git a/shorts/views.py b/shorts/views.py index 1aefb9fd..00519960 100644 --- a/shorts/views.py +++ b/shorts/views.py @@ -107,6 +107,8 @@ def normaliseScore(score): class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) + if self.request.user.is_superuser: + return obj if self.request.user.groups.filter(name="shorts_checker").exists(): return obj if self.request.user not in obj.author.all(): @@ -351,8 +353,8 @@ def mark_all(request, exam_pk, sk): def mark_review(request, exam_pk, sk): return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) -def mark_answer_override(request, exam_id, question_number, cid): - return mark_answer(request, exam_id, question_number, cid, override=True) +def mark_answer_override(request, exam_id, question_number, answer_id): + return mark_answer(request, exam_id, question_number, answer_id, override=True) def mark_answer(request, exam_id, question_number, answer_id, override=False): @@ -548,9 +550,8 @@ def mark_answer(request, exam_id, question_number, answer_id, override=False): }, ) -# @user_passes_test(user_is_admin, login_url="/accounts/login") @login_required -def mark(request, exam_pk, sk, unmarked_exam_answers_only=True): +def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): exam = get_object_or_404(Exam, pk=exam_pk) if not GenericExamViews.check_user_marker_access(request.user, exam_pk): @@ -591,6 +592,7 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True): "user_answers": user_answers, "unmarked_count": unmarked_count, "marker_unmarked_count": marker_unmarked_count, + "review": review, }, ) else: @@ -603,6 +605,7 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True): "question_details": question_details, "user_answers": user_answers, "unmarked_count": unmarked_count, + "review": review, }, ) @@ -756,7 +759,8 @@ def question_json(request, pk): def question_viewer(request): # question = get_object_or_404(Question, pk=pk) - ids = request.GET.get("ids").split(",") + ids_param = request.GET.get("ids") + ids = ids_param.split(",") if ids_param else [] return render( request,