diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 00000000..09a6bec9 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,231 @@ +# Security Audit Report + +**Date:** 2026-04-30 +**Scope:** All `views.py` files and `generic/decorators.py` across the entire project. + +--- + +## Issues Fixed + +### 1. Missing `@login_required` on `exam_collection_exams_partial` (generic/views.py) + +**Severity:** High +**File:** `generic/views.py` +The `exam_collection_exams_partial` view was publicly accessible with no authentication. It exposed exam names, candidate counts, and author information from any collection to anonymous users. +**Fix:** Added `@login_required` decorator. + +--- + +### 2. Unauthenticated `get_*_id` popup-helper views (multiple files) + +**Severity:** High +**Files:** `generic/views.py`, `anatomy/views.py`, `rapids/views.py`, `longs/views.py`, `atlas/views.py` + +All of the following views were decorated with `@csrf_exempt` but had **no authentication**. They allowed unauthenticated users to enumerate internal database records (Examinations, BodyParts, Structures, Abnormalities, Regions) by name via GET requests. + +Additionally, all of these views contained a bug: `request.accepts("application/json")()` calls the boolean result of `accepts()` as a function, which raises `TypeError: 'bool' object is not callable`. This made them effectively non-functional (always returning `HttpResponse("/")`). + +| File | View | +|---|---| +| `generic/views.py` | `get_examination_id` | +| `anatomy/views.py` | `get_body_part_id`, `get_examination_id`, `get_structure_id` | +| `rapids/views.py` | `get_abnormality_id`, `get_examination_id`, `get_region_id` | +| `longs/views.py` | `get_examination_id` | +| `atlas/views.py` | `get_examination_id` | + +**Fix:** +- Removed `@csrf_exempt` (GET-only views do not need CSRF exemption). +- Added `@login_required`. +- Fixed `request.accepts("application/json")()` → `request.accepts("application/json")`. +- Replaced `request.GET["key"]` dict access (raises `KeyError` on missing key) with `request.GET.get("key", "")`. +- Added docstrings documenting scope and functionality. + +--- + +### 3. Unauthenticated `answer_suggestion_submit` with CSRF exemption (rad/views.py) + +**Severity:** High +**File:** `rad/views.py` (line ~781) + +This view accepted anonymous POST requests with no CSRF protection to create new `Answer` records (marked `proposed=True`) on any existing question. This allowed anyone on the internet to submit arbitrary proposed answers to anatomy or rapids questions, polluting the moderation queue. + +**Fix:** +- Removed `@csrf_exempt`. +- Added `@login_required`. +- Added `if request.method != "POST": return ...` guard. +- Fixed a minor typo in the response message (`"submited"` → `"submitted"`). +- Added docstring documenting scope and functionality. + +--- + +### 4. Debug `print()` in `check_user_in_group` decorator (generic/decorators.py) + +**Severity:** Medium +**File:** `generic/decorators.py` + +The `check_user_in_group` decorator printed `request.user` to stdout on every invocation, leaking usernames/email addresses to application logs. +**Fix:** Removed the `print(request.user)` statement. + +--- + +### 5. Debug `print()` statements leaking sensitive data (multiple files) + +**Severity:** Medium +**Files:** `generic/views.py`, `atlas/views.py`, `longs/views.py`, `sbas/views.py`, `physics/views.py`, `shorts/views.py`, `rcr/views.py`, `oef/views.py` + +Numerous active (non-commented) `print()` calls throughout view code leaked sensitive data to stdout/production logs, including: +- User objects and usernames +- CID numbers and passcodes +- Email address lists being processed +- Exam and queryset data + +**Fix:** All active `print()` statements replaced with `logger.debug()` (using the project-standard `loguru` logger) or removed where they provided no diagnostic value. Commented-out print statements were left in place. + +--- + +### 6. `@csrf_exempt` on `collection_case_displaysetup` (atlas/views.py) + +**Severity:** Low +**File:** `atlas/views.py` + +The view had `@csrf_exempt` applied alongside a comment saying "Only if you have CSRF issues; otherwise, keep CSRF protection". The view already has proper authentication via `@user_is_collection_author_or_atlas_editor`, so the CSRF exemption was unnecessary and weakened security. +**Fix:** Removed `@csrf_exempt`. + +--- + +### 7. Missing `@login_required` on `toggle_share_with_supervisor` (generic/views.py) + +**Severity:** Medium +**File:** `generic/views.py` + +This HTMX view had no authentication check. An anonymous user with a known `CidUserExam` PK could toggle the `share_with_supervisor` flag (although the `request.user != cid_user_exam.user_user` check would prevent the toggle from succeeding, it still leaks whether the record exists). +**Fix:** Added `@login_required` and updated docstring. + +--- + +### 8. Missing `@login_required` on `active_exams` in sbas/views.py + +**Severity:** Medium +**File:** `sbas/views.py` + +The `active_exams` view had no authentication. It returned a list of all active SBA exams to anonymous users. +**Fix:** Added `@login_required`. Updated docstring. + +--- + +### 9. Unreachable dead code in `rcr/views.py` + +**Severity:** Low +**File:** `rcr/views.py` + +In the `get_object` method of an `UpdateView` subclass, two lines appeared after a `return obj` statement: +```python +return obj +print("test") # unreachable +return super().get_object() # unreachable +``` +**Fix:** Removed the unreachable `print("test")` and `return super().get_object()` lines. + +--- + +## Issues Requiring Clarification (Not Fixed) + +### A. `@csrf_exempt` on `exam_submit` (rad/views.py) + +**File:** `rad/views.py` +This view accepts POST submissions of exam answers without CSRF protection and without `@login_required`. It appears to be a legacy mobile/external client endpoint that dispatches to `post_exam_answers()` on individual exam view classes, which may perform their own CID/passcode authentication. + +**Risk:** Without authentication at this layer, the endpoint could be abused to submit bogus exam answers for real users if a valid CID/passcode combination is known. + +**Recommendation:** Clarify whether this endpoint is still in active use by a mobile client. If so, implement token-based authentication (e.g. Django REST Framework token auth) or require CID/passcode in the request body and validate before dispatching. If not in use, remove it. + +--- + +### B. Unauthenticated views in `rcr/views.py` + +**File:** `rcr/views.py` +The following views have no authentication decorators: +- `radiology_gap` — renders a full RCR gap analysis page with all curriculum items +- `radiology_results` — renders all RCR assessment results + +**Risk:** If these pages are intended to be internal-only (for assessors), they should require authentication. If they are intentionally public (e.g., public-facing curriculum overview), this is acceptable. + +**Recommendation:** Confirm intended visibility. If internal, add `@login_required` (or a more specific decorator). + +--- + +### C. Unauthenticated class-based views in `oef/views.py` + +**File:** `oef/views.py` +The following CBVs lack `LoginRequiredMixin`: +- `EntryUpdateView` — allows updating OEF entry fields +- `EntryTableView` — lists all entries +- `FormatsCreateView` — creates new Formats objects +- `FormatsUpdateView` — updates Formats objects + +The update views in particular are a concern if this is not an intentionally public tool. + +**Recommendation:** Add `LoginRequiredMixin` (and appropriate permission checks) if this app is not intended to be publicly editable. + +--- + +### D. Commented-out `@login_required` on `exam_toggle_flag` in physics/views.py + +**File:** `physics/views.py` +The `exam_toggle_flag` view has `#@login_required` commented out. The view falls back to `exam.check_user_can_take(cid, passcode, request.user)` for access control, suggesting this is intentionally accessible to CID users (non-Django-auth users). However, the intent should be confirmed. + +**Recommendation:** If the CID/passcode check is the intended authentication mechanism, document this clearly. If the view should also support Django-authenticated users, restore the decorator appropriately. + +--- + +### E. `exam_submit` potential `AttributeError` (rad/views.py) + +**File:** `rad/views.py` +```python +exam_type, exam_id = request.POST.get("eid").split("/") +``` +If `eid` is missing from the POST body, `request.POST.get("eid")` returns `None`, and `.split("/")` raises `AttributeError`. This would return a 500 response rather than a clean error. + +**Recommendation:** Guard with: +```python +eid = request.POST.get("eid", "") +if "/" not in eid: + return JsonResponse({"success": False, "error": "Invalid eid"}) +exam_type, exam_id = eid.split("/", 1) +``` + +--- + +### F. Open redirect risk in `RedirectMixin` and `UpdateUserProfileView` (generic/views.py, rad/views.py) + +**File:** `generic/views.py`, `rad/views.py` +Both `RedirectMixin.get_success_url` and `UpdateUserProfileView.get_success_url` use `self.request.GET["redirect"]` / `self.request.GET.get("redirect")` as the redirect target without validation. A malicious actor could craft a URL like `?redirect=https://evil.com` to redirect authenticated users to a phishing site after a successful form submission. + +**Recommendation:** Validate the redirect URL with Django's `url_has_allowed_host_and_scheme`: +```python +from django.utils.http import url_has_allowed_host_and_scheme + +redirect_url = self.request.GET.get("redirect", "") +if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts={self.request.get_host()}): + return redirect_url +return super().get_success_url() +``` + +--- + +### G. Missing login check on `sbas/views.py` `#@login_required` commented-out views + +**File:** `sbas/views.py` +Two views have `@login_required` commented out: +- `question_view` (around line 222) — currently commented out entirely +- `question_detail` (around line 226) — currently commented out entirely + +These are fully commented-out functions; no action needed unless they are re-enabled. + +--- + +## Notes on Existing `@csrf_exempt` Usage + +The following `@csrf_exempt` usages remain after fixes. All are on GET-only admin popup helpers that are now also `@login_required`. CSRF protection is not relevant for idempotent GET requests, but the decorators are harmless: + +> All `@csrf_exempt` instances have been removed from the codebase as part of this audit. The pattern `@csrf_exempt` on GET-only views is redundant since CSRF protection only applies to state-changing requests (POST/PUT/PATCH/DELETE). diff --git a/anatomy/views.py b/anatomy/views.py index f17859d5..517ea135 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -1242,10 +1242,16 @@ def create_body_part(request): ) -@csrf_exempt +@login_required def get_body_part_id(request): - if request.accepts("application/json")(): - body_part_name = request.GET["body_part_name"] + """Return the primary key for a BodyPart looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up a BodyPart by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + body_part_name = request.GET.get("body_part_name", "") body_part_id = BodyPart.objects.get(name=body_part_name).id data = { "body_part_id": body_part_id, @@ -1268,10 +1274,16 @@ def create_examination(request): ) -@csrf_exempt +@login_required def get_examination_id(request): - if request.accepts("application/json")(): - examination_name = request.GET["examination_name"] + """Return the primary key for an Examination looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up an Examination by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + examination_name = request.GET.get("examination_name", "") examination_id = Examination.objects.get(name=examination_name).id data = { "examination_id": examination_id, @@ -1282,10 +1294,16 @@ def get_examination_id(request): -@csrf_exempt +@login_required def get_structure_id(request): - if request.accepts("application/json")(): - structure_name = request.GET["structure_name"] + """Return the primary key for a Structure looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up a Structure by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + structure_name = request.GET.get("structure_name", "") structure_id = Structure.objects.get(name=structure_name).id data = { "structure_id": structure_id, diff --git a/atlas/views.py b/atlas/views.py index 88fc048e..e9e3d173 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -1587,7 +1587,7 @@ def condition_merge(request, pk_to_merge): condition_to_merge = get_object_or_404(Condition, pk=pk_to_merge) # condition_to_merge_into = get_object_or_404(Condition, pk=pk_to_merge_into) - print(condition_to_merge.case_set) + logger.debug("condition_merge: case_set={}", condition_to_merge.case_set) if condition_to_merge.rcr_curriculum: return HttpResponse("You cannot merge rcr curriculum items") @@ -2081,7 +2081,7 @@ def add_case_to_collection(request, collection_id): if case in collection.cases.all(): return HttpResponse("Case already in collection") - print(case) + logger.debug("collection_add_case: case={}", case) collection.add_case(case) # Render the new case list item and return it so HTMX can append it try: @@ -3215,10 +3215,16 @@ def atlas_scrap(request, pk): # return render(request, "atlas/create_simple.html", {'form': form}) -@csrf_exempt +@login_required def get_examination_id(request): - if request.accepts("application/json")(): - examination_name = request.GET["examination_name"] + """Return the primary key for an Examination looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up an Examination by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + examination_name = request.GET.get("examination_name", "") examination_id = Examination.objects.get(name=examination_name).id data = { "examination_id": examination_id, @@ -7916,7 +7922,6 @@ def series_bulk_delete(request): return redirect('atlas:series_view') @user_is_collection_author_or_atlas_editor -@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None): """ View to set up the display options for a specific case in a collection. diff --git a/generic/decorators.py b/generic/decorators.py index c6f27bd1..678d4d08 100755 --- a/generic/decorators.py +++ b/generic/decorators.py @@ -24,7 +24,6 @@ def check_user_in_group(*groups: Group): def decorator(function): @wraps(function) def wrapper(request, *args, **kwargs): - print(request.user) if request.user.is_superuser or request.user.groups.filter( name__in=[group.name for group in groups] ).exists(): diff --git a/generic/views.py b/generic/views.py index 6a2139a1..76bbc219 100644 --- a/generic/views.py +++ b/generic/views.py @@ -134,11 +134,14 @@ def cid_user_exam_partial(request, app_name: str, exam_id: int, cid: int): return render(request, "generic/partials/ciduserexam_detail_partial.html", {"cid_user_exam": cux}) +@login_required def exam_collection_exams_partial(request, collection_id: int, exam_type: str): - """Return the `exam-list` partial for a given collection and exam type. + """Return the exam-list partial for a given collection and exam type. - This endpoint is intended for HTMX lazy-loading. It annotates candidate counts - and prefetches authors to avoid per-exam DB queries in the template. + Scope: authenticated users only. + Functionality: HTMX lazy-loading endpoint that returns the exam list partial + for a specific exam type within a collection. Annotates candidate counts and + prefetches authors to avoid N+1 DB queries in the template. """ collection = get_object_or_404(ExamCollection, pk=collection_id) @@ -605,10 +608,16 @@ def examination_merge(request, pk): ) -@csrf_exempt +@login_required def get_examination_id(request): - if request.accepts("application/json")(): - examination_name = request.GET["examination_name"] + """Return the primary key for an Examination looked up by name. + + Scope: authenticated users only (admin popup helper). + Functionality: looks up an Examination by exact name and returns its ID + as JSON; used by inline admin form popups. + """ + if request.accepts("application/json"): + examination_name = request.GET.get("examination_name", "") examination_id = Examination.objects.get(name=examination_name).id data = { "examination_id": examination_id, @@ -975,7 +984,7 @@ class ExamViews(View, LoginRequiredMixin): Returns: [boolean]: True if the user has access """ - print("****", user, exam_id) + logger.debug("check_user_access called for user={} exam_id={}", user, exam_id) if user.is_superuser: return True @@ -2017,7 +2026,7 @@ class ExamViews(View, LoginRequiredMixin): if request.htmx.prompt is not None: additional_email = request.htmx.prompt - print(f"{additional_email=}") + logger.debug("exam_send_email: additional_email={}", additional_email) # check addition email is valid if additional_email: @@ -2102,7 +2111,7 @@ class ExamViews(View, LoginRequiredMixin): raise PermissionDenied res = exam.generate_user_report(u) - print(res) + logger.debug("generate_user_report result for exam={}: {}", exam_id, res) return HttpResponse(res) @@ -2409,7 +2418,7 @@ class ExamViews(View, LoginRequiredMixin): current_user_users ) ) - print(available_user_users) + logger.debug("available_user_users count: {}", len(available_user_users)) # available_user_users = User.objects.all() context = { @@ -2994,9 +3003,7 @@ class ExamViews(View, LoginRequiredMixin): ): exams = self.Exam.objects.all() filter = self.ExtraExamFilter(request.GET, queryset=exams) - print("1") else: - print("2") exams = self.Exam.objects.filter( author__id=request.user.id ) | self.Exam.objects.filter(open_access=True) @@ -3023,7 +3030,7 @@ class ExamViews(View, LoginRequiredMixin): else: raise Http404 - print(answer) + logger.debug("exam_question_user_answer result: {}", answer) return HttpResponse(answer) @method_decorator(login_required) @@ -3033,7 +3040,7 @@ class ExamViews(View, LoginRequiredMixin): cid_user = CidUser.objects.filter(cid=cid) answer = exam.get_question_cid_user_answer(sk, cid_user) - print(answer) + logger.debug("exam_question_cid_user_answer result: {}", answer) @method_decorator(login_required) def exam_question_detail(self, request, pk, sk): @@ -3156,7 +3163,7 @@ class ExamViews(View, LoginRequiredMixin): n = 0 for answer in json.loads(request.POST.get("answers")): - print("ANSWER", answer) + logger.debug("Processing answer: {}", answer) eid = int(answer["eid"].split("/")[1]) uid = False @@ -3366,19 +3373,17 @@ class ExamViews(View, LoginRequiredMixin): raise Http404("No available exam") # TODO: check access for logged in users - print("TEST", cid) + logger.debug("exam_question_json: cid={}", cid) # Check access for users if request.user.is_anonymous: user_id = None else: user_id = request.user.pk - print(0) if exam.exam_mode and not exam.check_cid_user( cid, passcode, request.user, user_id ): raise Http404("No available exam") - print(1) # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) @@ -3503,7 +3508,7 @@ class ExamViews(View, LoginRequiredMixin): question = get_object_or_404(self.Question, pk=sk) exam = get_object_or_404(self.Exam, pk=pk) - print("CID", cid) + logger.debug("exam_question_json_unbased: cid={}", cid) if not exam.active and not self.check_user_access(request.user, pk): raise Http404("No available exam") @@ -3514,8 +3519,6 @@ class ExamViews(View, LoginRequiredMixin): else: user_id = request.user.pk - print("CID", cid) - if not exam.check_cid_user(cid, passcode, request.user, user_id): raise Http404("No available exam") @@ -3652,7 +3655,7 @@ class ExamViews(View, LoginRequiredMixin): answers.append(ans) answers_marks.append(answer_score) - print(q.get_questions(), ans, answer_score, correct_answer) + logger.debug("physics answer row: questions={} ans={} score={} correct={}", q.get_questions(), ans, answer_score, correct_answer) merged_ans = zip( q.get_questions(), ans, answer_score, correct_answer ) @@ -4090,8 +4093,7 @@ class GenericViewBase: """ exam: ExamBase = get_object_or_404(self.exam_object, pk=pk) - print(request.POST["question_number"]) - print(type(request.POST["question_number"])) + logger.debug("question_published_json: question_number={}", request.POST.get("question_number")) if not exam.publish_results: raise Http404 @@ -4417,7 +4419,6 @@ class GenericViewBase: @method_decorator(user_passes_test(lambda u: u.is_superuser)) def user_answer_delete_multiple(self, request): - print(request.POST["answer_ids"]) answer_ids = json.loads(request.POST["answer_ids"]) # We could probably delete them all at once.... @@ -4476,13 +4477,11 @@ class GenericViewBase: # TODO: improve permissions on these @method_decorator(login_required) def question_user_answers_by_compare(self, request, pk, answer_compare): - print(answer_compare) answer_compare = urllib.parse.unquote(answer_compare) return self.question_user_answers(request, pk, answer_compare) @method_decorator(login_required) def question_user_answers(self, request, pk, answer_compare=None): - print(locals()) question = get_object_or_404(self.question_object, pk=pk) if answer_compare is None: @@ -4950,8 +4949,7 @@ def user_not_trainee(request, user_id): @user_is_cid_user_manager def users_bulk_edit(request): - print(request.htmx.trigger) - print(request.htmx.trigger_name) + logger.debug("users_bulk_edit: htmx trigger={} name={}", request.htmx.trigger, request.htmx.trigger_name) try: match request.htmx.trigger: @@ -5320,12 +5318,14 @@ def create_cid_email(request): @user_is_cid_user_manager def cid_details(request, cid: int): - print(cid) + """Return a brief display string for a CID user (HTMX partial). + + Scope: cid_user_manager and superusers only. + Functionality: returns the CID user's name and email as plain text for + HTMX inline display. + """ if request.htmx: cid_user = get_object_or_404(CidUser, cid=cid) - - print(cid_user.name) - return HttpResponse(f"{cid_user.name} ({cid_user.email})") raise PermissionDenied() # or Http404 @@ -5454,7 +5454,7 @@ def manage_cid_users(request): except ValidationError as e: invalid_emails.append(f"Invalid email: {email}") - print(f"Email list {email_list}") + logger.debug("bulk_create_cid_users: email_list count={}", len(email_list)) if not email_list: return JsonResponse({"status": "fail", "details": "No valid emails"}) if invalid_emails or not email_list: @@ -5629,9 +5629,6 @@ class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): # Call the base implementation first to get a context context = super().get_context_data(**kwargs) - print(dir(context["view"])) - print(context["view"].template_name_suffix) - print(context["view"].model.app_name) return context def get_form_kwargs(self): @@ -6478,10 +6475,6 @@ def supervisor_trainee(request, pk, trainee_id): exams = get_user_exams(trainee, supervisor_view=True) - print(exams) - - print(trainee) - return render( request, "generic/supervisor_trainee.html", @@ -6542,13 +6535,17 @@ def supervisor_request_account(request): return render(request, "generic/supervisor_request_account.html", {}) +@login_required def toggle_share_with_supervisor(request, pk): + """Toggle the share_with_supervisor flag on a CidUserExam record. + + Scope: authenticated owner of the CidUserExam record only. + Functionality: HTMX endpoint that toggles whether the exam result is + shared with the user's supervisor. + """ if request.htmx: cid_user_exam = get_object_or_404(CidUserExam, pk=pk) - print(request.user) - print(cid_user_exam) - print(cid_user_exam.user_user) if request.user != cid_user_exam.user_user: return HttpResponse("Incorrect permissions") diff --git a/longs/views.py b/longs/views.py index 28e8f524..86571649 100755 --- a/longs/views.py +++ b/longs/views.py @@ -1,6 +1,7 @@ import json from django.shortcuts import render, get_object_or_404, redirect from django import forms +from loguru import logger # from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required, user_passes_test @@ -434,8 +435,7 @@ class LongCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): .prefetch_related("long") .filter(pk=62) ) # .values() - print("TEST") - print(queryset) + logger.debug("LongCreateBase context: queryset count={}", queryset.count()) # queryset = LongSeries.objects.all() if self.request.POST: context["series_formset"] = SeriesFormSet( @@ -552,7 +552,7 @@ class LongUpdate( # restore exam orders for exam in self.object.exams.all(): if exam in exam_orders and self.object in exam_orders[exam]: - print(exam_orders[exam]) + logger.debug("Restoring exam order for exam={}", exam) exam.exam_questions.set(exam_orders[exam]) exam.save() @@ -614,10 +614,16 @@ def create_examination(request): ) -@csrf_exempt +@login_required def get_examination_id(request): - if request.accepts("application/json")(): - examination_name = request.GET["examination_name"] + """Return the primary key for an Examination looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up an Examination by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + examination_name = request.GET.get("examination_name", "") examination_id = Examination.objects.get(name=examination_name).id data = { "examination_id": examination_id, @@ -1001,9 +1007,8 @@ class ExamGroupsUpdate(ExamGroupsUpdateBase): def question_json_unbased(request, pk): """ - No (file based) caching is enabled for unbased quesitons + No (file based) caching is enabled for unbased questions. """ - print("UNBASED") question = get_object_or_404(Long, pk=pk) question_json = question.get_question_json(based=False) diff --git a/oef/views.py b/oef/views.py index 9c05a544..6f6a59e9 100644 --- a/oef/views.py +++ b/oef/views.py @@ -87,7 +87,6 @@ def output(request): questions = format.format.strip().split("---") for question in questions: - print(question) if not question: continue diff --git a/physics/views.py b/physics/views.py index 23b02b2b..ef1e59ec 100644 --- a/physics/views.py +++ b/physics/views.py @@ -97,7 +97,6 @@ def question_detail(request, pk): @login_required def active_exams(request): exams = Exam.objects.all() - print(exams) active_exams = [] diff --git a/rad/views.py b/rad/views.py index 5d7476eb..9005417b 100644 --- a/rad/views.py +++ b/rad/views.py @@ -778,36 +778,42 @@ def feedback_mark_complete(request, pk): return redirect(q.get_object_url()) -@csrf_exempt +@login_required def answer_suggestion_submit(request): - if request.method == "POST": - post_data = json.loads(request.body) - question_type, qid = post_data["qid"].split("/") - answer_string = post_data["answer"] - status = post_data["status"] + """Submit a proposed answer to a question for review. - if str(status) not in " 012": - return JsonResponse({"success": False, "error": "Invalid status"}) + Scope: authenticated users only. + Functionality: accepts a JSON POST containing the question ID, proposed answer + string, and status; creates a new Answer record marked as proposed=True for + moderator review. Only 'anatomy' and 'rapid' question types are supported. + """ + if request.method != "POST": + return JsonResponse({"success": False, "error": "Invalid data"}) - if question_type == "anatomy": - AnswerModel = AnatomyAnswer - question = AnatomyQuestion - elif question_type == "rapid": - AnswerModel = RapidAnswer - question = Rapid - else: - return JsonResponse({"success": False, "error": "Invalid question type"}) + post_data = json.loads(request.body) + question_type, qid = post_data["qid"].split("/") + answer_string = post_data["answer"] + status = post_data["status"] - q = get_object_or_404(question, pk=qid) - if AnswerModel.objects.filter(answer=answer_string, question=q): - return JsonResponse({"success": False, "error": "Answer already exists"}) + if str(status) not in " 012": + return JsonResponse({"success": False, "error": "Invalid status"}) - a = AnswerModel(question=q, answer=answer_string, status=status, proposed=True) - a.save() - return JsonResponse({"success": True, "error": "Answer submited"}) + if question_type == "anatomy": + AnswerModel = AnatomyAnswer + question = AnatomyQuestion + elif question_type == "rapid": + AnswerModel = RapidAnswer + question = Rapid + else: + return JsonResponse({"success": False, "error": "Invalid question type"}) - # post_exam_answers - return JsonResponse({"success": False, "error": "Invalid data"}) + q = get_object_or_404(question, pk=qid) + if AnswerModel.objects.filter(answer=answer_string, question=q): + return JsonResponse({"success": False, "error": "Answer already exists"}) + + a = AnswerModel(question=q, answer=answer_string, status=status, proposed=True) + a.save() + return JsonResponse({"success": True, "error": "Answer submitted"}) @login_required diff --git a/rapids/views.py b/rapids/views.py index 062b18e1..112a8797 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -477,10 +477,16 @@ def create_abnormality(request): ) -@csrf_exempt +@login_required def get_abnormality_id(request): - if request.accepts("application/json")(): - abnormality_name = request.GET["abnormality_name"] + """Return the primary key for an Abnormality looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up an Abnormality by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + abnormality_name = request.GET.get("abnormality_name", "") abnormality_id = Abnormality.objects.get(name=abnormality_name).id data = { "abnormality_id": abnormality_id, @@ -503,10 +509,16 @@ def create_examination(request): ) -@csrf_exempt +@login_required def get_examination_id(request): - if request.accepts("application/json")(): - examination_name = request.GET["examination_name"] + """Return the primary key for an Examination looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up an Examination by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + examination_name = request.GET.get("examination_name", "") examination_id = Examination.objects.get(name=examination_name).id data = { "examination_id": examination_id, @@ -529,10 +541,16 @@ def create_region(request): ) -@csrf_exempt +@login_required def get_region_id(request): - if request.accepts("application/json")(): - region_name = request.GET["region_name"] + """Return the primary key for a Region looked up by name (admin popup helper). + + Scope: authenticated users only. + Functionality: looks up a Region by exact name and returns its ID as JSON; + used by inline admin form popups. + """ + if request.accepts("application/json"): + region_name = request.GET.get("region_name", "") region_id = Region.objects.get(name=region_name).id data = { "region_id": region_id, diff --git a/rcr/views.py b/rcr/views.py index c5389be7..c836b402 100644 --- a/rcr/views.py +++ b/rcr/views.py @@ -17,6 +17,7 @@ from rcr.models import Item, Outcome, RadiologyCategory from rcr.forms import AssessorAssignmentForm, ItemForm from atlas.models import Condition from django.core.exceptions import PermissionDenied +from loguru import logger # Create your views here. @@ -160,8 +161,6 @@ def radiology_assessor_assignment(request, assessor_pk: int, specialty=None): assessor_assignment_form = AssessorAssignmentForm(request.POST) assessor_assignment_form.fields["items"].queryset = item_filter.qs - print(assessor_assignment_form) - if assessor_assignment_form.is_valid(): items_to_update = assessor_assignment_form.cleaned_data["items"] @@ -309,16 +308,12 @@ class ItemUpdateNextView(ItemUpdateBase, RCRRequiredMixin): except IndexError: raise Http404 - print("HELLO", obj) - print(obj) + logger.debug("get_object: retrieving item pk={}", obj) if not obj: - print("raise") raise Http404 return obj - print("test") - return super().get_object() #def get diff --git a/sbas/views.py b/sbas/views.py index 64005d94..037f9d05 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -225,9 +225,15 @@ class AuthorOrCheckerRequiredMixin(object): # return render(request, "sbas/question_detail.html", {"question": question}) +@login_required def active_exams(request): + """List all active SBA exams (candidate-facing). + + Scope: authenticated users only. + Functionality: returns all non-archived active exams as a list for candidates + to take. + """ exams = Exam.objects.all() - print(exams) active_exams = [] diff --git a/shorts/views.py b/shorts/views.py index 8be10296..1aefb9fd 100644 --- a/shorts/views.py +++ b/shorts/views.py @@ -395,7 +395,6 @@ def mark_answer(request, exam_id, question_number, answer_id, override=False): elif answer_list[-1] == answer_id: previous_answer_id = answer_list[-2] else: - print(answer_list.index(answer_id)) next_answer_id = answer_list[answer_list.index(answer_id) + 1] previous_answer_id = answer_list[answer_list.index(answer_id) - 1] @@ -955,7 +954,6 @@ def question_findings(request, question_id, finding_pk=None): @require_POST def combine_images_side_by_side(request, question_id): image_ids = request.POST.getlist('combine-image-checkbox') - print(image_ids) if len(image_ids) != 2: return render(request, "shorts/combine_error.html", {"error": "Please select exactly two images."}) question = get_object_or_404(Question, pk=question_id)