import secrets from typing import Any, Optional import os import re from io import StringIO from datetime import datetime from django.conf import settings from django_tables2 import SingleTableMixin from atlas.models import CaseCollection, CidReportAnswer, Case as AtlasCase from generic.decorators import user_is_cid_user_manager from generic.filters import UserUserFilter from generic.tables import UserUserTable from generic.views import ( CidManagerRequiredMixin, RedirectMixin, get_question_and_content_type, get_user_exams, get_exam_model_from_app_name, ) from django.core.exceptions import PermissionDenied, ObjectDoesNotExist from django.shortcuts import render, get_object_or_404, redirect from django.views.decorators.csrf import csrf_exempt from django import forms from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.utils.html import format_html from django.utils.safestring import mark_safe # from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic import ListView, TemplateView from django.db.models.functions import Lower from django.core.cache import cache from django.urls import reverse_lazy, reverse from django.http import Http404, JsonResponse from django.http import HttpResponseRedirect, HttpResponse from django.views.decorators.http import require_POST from django.core.management import call_command, CommandError from physics.models import UserAnswer as PhysicsUserAnswer from physics.models import Exam as PhysicsExam from anatomy.models import UserAnswer as AnatomyUserAnswer from anatomy.models import Exam as AnatomyExam from anatomy.models import AnatomyQuestion from anatomy.models import Answer as AnatomyAnswer from rad.filters import UserListFilter from helpers.cimar import CimarAPI, InvalidCredentials, NoSession from rapids.models import UserAnswer as RapidsUserAnswer from rapids.models import Exam as RapidsExam from rapids.models import Rapid from rapids.models import Answer as RapidAnswer from longs.models import UserAnswer as LongsUserAnswer from longs.models import Exam as LongsExam from longs.models import Long from sbas.models import UserAnswer as SbasUserAnswer from sbas.models import Exam as SbasExam from anatomy.views import GenericExamViews as AnatomyExamViews from rapids.views import GenericExamViews as RapidsExamsViews from shorts.views import GenericExamViews as ShortsExamsViews from longs.views import GenericExamViews as LongsExamsViews from generic.forms import QuestionNoteForm from generic.models import ( CidUser, CimarCase, ExamCollection, QuestionNote, Supervisor, UserGrades, UserProfile, USER_EXAM_TYPES, ) from django.contrib.auth.models import Group from generic.models import UserUserGroup from django_filters.views import FilterView import json from django.template import RequestContext from django.db.models import Q from dal import autocomplete from loguru import logger from .logging_setup import get_log_file_path import psutil def _media_file_inventory(path: str) -> dict[str, int]: """Return a mapping of file path to byte size for files under a directory.""" if not path or not os.path.exists(path): return {} inventory: dict[str, int] = {} for root, _, files in os.walk(path): for filename in files: file_path = os.path.join(root, filename) try: inventory[file_path] = os.path.getsize(file_path) except OSError: continue return inventory def feedback_checker(user): return user.groups.filter(name="feedback_checker").exists() @user_passes_test(lambda u: u.is_superuser) def server(request): return render(request, "server.html") @user_is_cid_user_manager def people(request): """Render the people management overview or search users via HTMX. Scope: managers and superusers. Functionality: provides details on user roles/groups, and a single search input that filters users dynamically via HTMX GET requests. """ if request.htmx: q = request.GET.get("q", "").strip() if not q: return HttpResponse("") users = ( User.objects.filter( Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(email__icontains=q) | Q(username__icontains=q) ) .prefetch_related( "userprofile", "userprofile__grade", "userprofile__supervisor", "user_groups" ) .order_by("first_name", "last_name", "username")[:20] ) return render( request, "rad/partials/_people_search_results.html", {"users": users, "q": q}, ) return render(request, "people.html", {}) @login_required def profile(request): """Render the current user's profile page with group management context. Scope: authenticated user's own profile view. Functionality: provides user details, available groups, and supervisor account state for template actions. """ user = request.user available_groups = UserUserGroup.objects.filter(archive=False).exclude( pk__in=user.user_groups.values_list("pk", flat=True) ) available_auth_groups = Group.objects.exclude( pk__in=user.groups.values_list("pk", flat=True) ) is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists() supervisor_account = Supervisor.objects.filter(user=user).first() return render( request, "profile.html", { "user": user, "available_groups": available_groups, "available_auth_groups": available_auth_groups, "is_cid_user_manager": is_cid_user_manager, "supervisor_account": supervisor_account, "supervisor_action": request.GET.get("supervisor_action", ""), "password_reset_action": request.GET.get("password_reset_action", ""), }, ) def index(request): collections = [] if request.user.is_authenticated: collections = ExamCollection.objects.filter(archive=False, author=request.user) rcr_assessor = request.user.groups.filter( Q(name="rcr_radiology_assessor") | Q(name="rcr_oncology_assessor") | Q(name="rcr_assessor") ).exists() return render( request, "index.html", {"rcr_assessor": rcr_assessor, "collections": collections}, ) @user_is_cid_user_manager def account_profile(request, slug): """Render an account profile page for administrators. Scope: cid_user_manager and superusers viewing another user's profile. Functionality: exposes profile details, group controls, and supervisor account creation/link status for admin actions. """ user = get_object_or_404(User, username=slug) available_groups = UserUserGroup.objects.filter(archive=False).exclude( pk__in=user.user_groups.values_list("pk", flat=True) ) available_auth_groups = Group.objects.exclude( pk__in=user.groups.values_list("pk", flat=True) ) is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists() supervisor_account = Supervisor.objects.filter(user=user).first() return render( request, "profile.html", { "user": user, "available_groups": available_groups, "available_auth_groups": available_auth_groups, "is_cid_user_manager": is_cid_user_manager, "supervisor_account": supervisor_account, "supervisor_action": request.GET.get("supervisor_action", ""), "password_reset_action": request.GET.get("password_reset_action", ""), }, ) @require_POST @user_is_cid_user_manager def account_set_supervisor(request, slug): """Create or link a Supervisor record for an existing user. Scope: cid_user_manager and superusers only. Functionality: one-click action from profile pages to assign a user as a supervisor account, reusing an existing supervisor by email when safe. """ user = get_object_or_404(User, username=slug) next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username}) email = (user.email or "").strip().lower() if not email: return redirect(f"{next_url}?supervisor_action=no_email") existing_for_user = Supervisor.objects.filter(user=user).first() if existing_for_user: return redirect(f"{next_url}?supervisor_action=already_linked") supervisor = Supervisor.objects.filter(email__iexact=email).first() if supervisor: if supervisor.user and supervisor.user != user: return redirect(f"{next_url}?supervisor_action=email_in_use") supervisor.user = user if not supervisor.name: supervisor.name = user.get_full_name().strip() or user.username supervisor.save() return redirect(f"{next_url}?supervisor_action=linked") name = user.get_full_name().strip() or user.username Supervisor.objects.create(email=email, name=name, user=user) return redirect(f"{next_url}?supervisor_action=created") @require_POST @user_is_cid_user_manager def account_send_password_reset(request, slug): """Send a password reset email for an existing user from their profile page.""" user = get_object_or_404(User, username=slug) next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username}) email = (user.email or user.username or "").strip() if not email: return redirect(f"{next_url}?password_reset_action=no_email") if not user.is_active: return redirect(f"{next_url}?password_reset_action=inactive") form = PasswordResetForm({"email": email}) if not form.is_valid(): return redirect(f"{next_url}?password_reset_action=invalid") try: form.save( request=request, use_https=request.is_secure(), from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), ) except Exception: logger.exception("Failed to send password reset email for user {}", user.pk) return redirect(f"{next_url}?password_reset_action=error") return redirect(f"{next_url}?password_reset_action=sent") @user_passes_test(lambda u: u.is_superuser) def logs_view(request): """Display production logs with search/filter controls. Scope: superusers only. Functionality: reads the configured application log file and displays entries with optional text search filter. Supports ?all=1 to show all entries without pagination limit. """ log_file_path = str(get_log_file_path()) search_query = request.GET.get("q", "").strip() level_filter = request.GET.get("level", "").strip().upper() show_all = request.GET.get("all") == "1" raw_mode = request.GET.get("raw") == "1" include_noise = request.GET.get("include_noise") == "1" date_from_raw = request.GET.get("date_from", "").strip() date_to_raw = request.GET.get("date_to", "").strip() time_from_raw = request.GET.get("time_from", "").strip() time_to_raw = request.GET.get("time_to", "").strip() action_message = "" if request.method == "POST" and request.POST.get("action") == "clear": try: os.makedirs(os.path.dirname(log_file_path), exist_ok=True) with open(log_file_path, "w", encoding="utf-8"): pass action_message = "Log file cleared." except Exception as exc: action_message = f"Failed to clear log file: {exc}" def _parse_log_timestamp(timestamp_text: str) -> datetime | None: if not timestamp_text: return None for timestamp_format in ("%d/%b/%Y %H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: return datetime.strptime(timestamp_text, timestamp_format) except ValueError: continue return None def _parse_date_input(value: str): if not value: return None try: return datetime.strptime(value, "%Y-%m-%d").date() except ValueError: return None def _parse_time_input(value: str): if not value: return None for time_format in ("%H:%M", "%H:%M:%S"): try: return datetime.strptime(value, time_format).time() except ValueError: continue return None date_from = _parse_date_input(date_from_raw) date_to = _parse_date_input(date_to_raw) time_from = _parse_time_input(time_from_raw) time_to = _parse_time_input(time_to_raw) if raw_mode: if not os.path.exists(log_file_path): return HttpResponse( f"Log file not found at {log_file_path}\n", content_type="text/plain; charset=utf-8", status=404, ) try: with open(log_file_path, "r", encoding="utf-8", errors="ignore") as f: raw_content = f.read() except Exception as e: return HttpResponse( f"Error reading log file: {str(e)}\n", content_type="text/plain; charset=utf-8", status=500, ) return HttpResponse(raw_content, content_type="text/plain; charset=utf-8") log_header_re = re.compile( r"^\[(?P[^\]]+)\]\s+(?P[A-Z]+)\s+\[(?P[^:\]]+):(?P\d+)\]\s+(?P.*)$" ) parsed_entries: list[dict[str, Any]] = [] if os.path.exists(log_file_path): try: with open(log_file_path, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() current_entry = None for raw_line in lines: line = raw_line.rstrip("\n") if not line.strip(): if current_entry is not None: current_entry["details"].append("") continue header_match = log_header_re.match(line) if header_match: if current_entry is not None: parsed_entries.append(current_entry) current_entry = { "timestamp": header_match.group("timestamp"), "level": header_match.group("level"), "logger": header_match.group("logger"), "source_line": header_match.group("line"), "message": header_match.group("message"), "details": [], } continue if current_entry is not None: current_entry["details"].append(line) else: parsed_entries.append( { "timestamp": "", "level": "RAW", "logger": "", "source_line": "", "message": line, "details": [], } ) if current_entry is not None: parsed_entries.append(current_entry) except Exception as e: parsed_entries = [ { "timestamp": "", "level": "ERROR", "logger": "logs_view", "source_line": "", "message": f"Error reading log file: {str(e)}", "details": [], } ] else: parsed_entries = [ { "timestamp": "", "level": "ERROR", "logger": "logs_view", "source_line": "", "message": f"Log file not found at {log_file_path}", "details": [], } ] # Most recent first. parsed_entries = list(reversed(parsed_entries)) available_levels = sorted({entry["level"] for entry in parsed_entries if entry["level"]}) filtered_entries = [] search_lower = search_query.lower() noise_suppressed = 0 for entry in parsed_entries: # Django template debug logs can be very noisy and usually do not indicate # fatal issues. Hide them by default, with an explicit opt-in toggle. is_template_debug_noise = ( entry.get("level") == "DEBUG" and entry.get("logger") == "django.template" and ( "Exception while resolving variable" in entry.get("message", "") or any("VariableDoesNotExist" in d for d in entry.get("details", [])) ) ) if is_template_debug_noise and not include_noise: noise_suppressed += 1 continue if level_filter and entry["level"] != level_filter: continue haystack = "\n".join( [ entry.get("timestamp", ""), entry.get("level", ""), entry.get("logger", ""), entry.get("message", ""), "\n".join(entry.get("details", [])), ] ).lower() if search_lower and search_lower not in haystack: continue entry_timestamp = _parse_log_timestamp(entry.get("timestamp", "")) if date_from or date_to or time_from or time_to: if entry_timestamp is None: continue if date_from and entry_timestamp.date() < date_from: continue if date_to and entry_timestamp.date() > date_to: continue if time_from and entry_timestamp.time() < time_from: continue if time_to and entry_timestamp.time() > time_to: continue filtered_entries.append(entry) total = len(filtered_entries) limit = None if show_all else 500 displayed_entries = filtered_entries[:limit] if limit else filtered_entries return render( request, "logs_viewer.html", { "entries": displayed_entries, "total_entries": total, "search_query": search_query, "level_filter": level_filter, "available_levels": available_levels, "log_file_path": log_file_path, "show_all": show_all, "limit": limit, "raw_url": f"{reverse('logs_view')}?raw=1", "include_noise": include_noise, "noise_suppressed": noise_suppressed, "date_from": date_from_raw, "date_to": date_to_raw, "time_from": time_from_raw, "time_to": time_to_raw, "action_message": action_message, }, ) @login_required @user_is_cid_user_manager def media_cleanup(request): """Run django-unused-media cleanup from a manager-facing maintenance page. Scope: cid_user_manager group members and superusers. Functionality: supports safe preview (dry-run) and confirmed deletion runs, returning HTMX fragments for inline results. """ context: dict[str, Any] = { "ran": False, "action": "preview", "minimum_file_age": 60, "remove_empty_dirs": True, "exclude": "", } if request.method == "POST": action = request.POST.get("action", "preview") dry_run = action != "cleanup" exclude_raw = (request.POST.get("exclude") or "").strip() exclude = [line.strip() for line in exclude_raw.splitlines() if line.strip()] remove_empty_dirs = request.POST.get("remove_empty_dirs") == "on" min_age_raw = (request.POST.get("minimum_file_age") or "60").strip() try: minimum_file_age = max(0, int(min_age_raw)) except (TypeError, ValueError): minimum_file_age = 0 out = StringIO() success = True command_kwargs: dict[str, Any] = { "dry_run": dry_run, "no_input": True, "interactive": False, "minimum_file_age": minimum_file_age, "remove_empty_dirs": remove_empty_dirs, "verbosity": 2, "stdout": out, } if exclude: command_kwargs["exclude"] = exclude error_message = "" media_inventory_before = _media_file_inventory(settings.MEDIA_ROOT) media_inventory_after = media_inventory_before try: call_command("cleanup_unused_media", **command_kwargs) except CommandError as exc: success = False error_message = str(exc) except Exception as exc: # pragma: no cover success = False error_message = str(exc) media_inventory_after = _media_file_inventory(settings.MEDIA_ROOT) media_size_before = sum(media_inventory_before.values()) media_size_after = sum(media_inventory_after.values()) removed_files = set(media_inventory_before) - set(media_inventory_after) output = out.getvalue().strip() # Use before/after total delta so reported recovered space matches real disk impact. recovered_size = max(0, media_size_before - media_size_after) context.update( { "ran": True, "success": success, "action": action, "dry_run": dry_run, "minimum_file_age": minimum_file_age, "remove_empty_dirs": remove_empty_dirs, "exclude": exclude_raw, "output": output, "error_message": error_message, "media_size_before": media_size_before, "media_size_after": media_size_after, "recovered_size": recovered_size, "removed_file_count": len(removed_files), } ) if request.htmx: return render(request, "rad/partials/_media_cleanup_result.html", context) return render(request, "rad/media_cleanup.html", context) def cid_selector(request): return render( request, "cid_selector.html", ) @login_required @user_passes_test(lambda u: u.is_superuser) def cid_scores_admin(request, cid): cid_user = CidUser.objects.filter(cid=cid).first() if not cid_user: raise Http404("CID not found") return cid_user_overview(request, cid_user.cid, cid_user.passcode) @login_required # @user_passes_test(lambda u: u.is_superuser) @user_is_cid_user_manager def cid_results(request, cid): cid_user = CidUser.objects.filter(cid=cid).first() if not cid_user: raise Http404("CID not found") report = cid_user.generate_exam_report() return render( request, "cid_results.html", { "report": report, }, ) @user_passes_test(lambda u: u.is_superuser) def user_scores_admin(request, user_id): try: user = User.objects.get(id=user_id) except User.DoesNotExist: raise Http404("Cid not found") return user_scores(request, user=user) @login_required def user_scores(request, user=None): admin = True if user is None: user = request.user admin = False from django.contrib.contenttypes.models import ContentType from generic.models import UserHiddenItem show_hidden = request.GET.get("show_hidden") in ("true", "1") # Fetch user's hidden items map: {content_type_id: {object_ids}} hidden_map = {} for ct_id, obj_id in UserHiddenItem.objects.filter(user=user).values_list("content_type_id", "object_id"): hidden_map.setdefault(ct_id, set()).add(obj_id) # Filter Exam Results list (all_exams) exams = get_user_exams(user) filtered_exams = [] for exam_type, exams_qs in exams: exam_model = exams_qs.model ct_id = ContentType.objects.get_for_model(exam_model).id hidden_ids = hidden_map.get(ct_id, set()) if not show_hidden: exams_qs = exams_qs.exclude(id__in=hidden_ids) exams_list = list(exams_qs) for exam in exams_list: exam.is_hidden = exam.id in hidden_ids if exams_list: filtered_exams.append((exam_type, exams_list)) # Filter Assigned Exams list (available_exams) available_exams = [] for n, t in USER_EXAM_TYPES: exam_rel = getattr(user, t) if exam_rel.exists(): temp_exams = exam_rel.filter(exam_mode=True, archive=False).order_by("name") exam_model = temp_exams.model ct_id = ContentType.objects.get_for_model(exam_model).id hidden_ids = hidden_map.get(ct_id, set()) if not show_hidden: temp_exams = temp_exams.exclude(id__in=hidden_ids) temp_exams_list = list(temp_exams) for exam in temp_exams_list: exam.is_hidden = exam.id in hidden_ids if temp_exams_list: available_exams.append((n, temp_exams_list)) return render( request, "user_scores.html", { "all_exams": filtered_exams, "cid_user": user, "available_exams": available_exams, "case_collections": [], "admin": admin, "show_hidden": show_hidden, }, ) @login_required def user_marking(request): """Main page for user marking overview. Uses HTMX to fetch per-app partials.""" apps = ["physics", "anatomy", "rapids", "shorts", "longs", "sbas"] return render(request, "rad/user_marking.html", {"apps": apps}) @login_required def user_marking_partial(request, exam_type: str): """Return an HTMX partial listing exams the user can mark for a given exam type. Each exam is checked for whether it has any unmarked answers (using question.get_unmarked_user_answer_count when available). """ user = request.user try: ExamModel = get_exam_model_from_app_name(exam_type) except Exception: return HttpResponse("Unknown exam type", status=400) # Exams where user is an author or an authorised marker exams_qs = ( ExamModel.objects.filter(archive=False, exam_mode=True) .filter(Q(author=user) | Q(markers=user)) .distinct() .order_by("name") .prefetch_related("author", "markers") ) exams_data = [] for exam in exams_qs: has_unmarked = False unmarked_count = 0 # iterate questions and use their helper to count unmarked answers when available for q in exam.get_questions(): count = 0 # Prefer question-provided helper if present helper = getattr(q, "get_unmarked_user_answer_count", None) if callable(helper): try: count = helper(exam_pk=exam.pk, marker=user) except TypeError: count = helper(exam_pk=exam.pk) except Exception: count = 0 else: # Fallback: inspect related answer manager if present rel_manager = None for rel_name in ("cid_user_answers", "user_answers", "answers", "useranswer_set"): if hasattr(q, rel_name): rel_manager = getattr(q, rel_name) break if rel_manager is not None: try: ans_model = rel_manager.model # If the answer model has a `score` field, count answers that are unmarked field_names = [f.name for f in ans_model._meta.get_fields()] if "score" in field_names: # Try to detect an UNMARKED constant on the model unmarked_vals = [None, ""] model_score_const = getattr(ans_model, "ScoreOptions", None) or getattr(ans_model, "MarkOptions", None) if model_score_const is not None: # attempt to use any UNMARKED attribute unmarked_val = getattr(model_score_const, "UNMARKED", None) if unmarked_val is not None: unmarked_vals.append(unmarked_val) count = rel_manager.filter(exam__id=exam.pk).filter(score__in=unmarked_vals).count() else: # No score field — likely auto-marked; treat as 0 count = 0 except Exception: count = 0 try: c = int(count) except Exception: c = 0 if c: has_unmarked = True unmarked_count += c exams_data.append({"exam": exam, "has_unmarked": has_unmarked, "unmarked_count": unmarked_count}) return render(request, "generic/partials/user_marking_exam_list.html", {"exams_data": exams_data, "exam_type": exam_type}) def cid_user_overview(request, cid, passcode): # exam = get_object_or_404(Exam, pk=pk) cid_user = CidUser.objects.filter(cid=cid).first() if not cid_user or cid_user.passcode != passcode: raise Http404("CID / Passcode combination not found") EXAM_ANSWER_MAP = { "physics": (PhysicsUserAnswer, PhysicsExam), "anatomy": (AnatomyUserAnswer, AnatomyExam), "rapids": (RapidsUserAnswer, RapidsExam), "longs": (LongsUserAnswer, LongsExam), "sbas": (SbasUserAnswer, SbasExam), } kwargs = {"exam_mode": True, "archive": False} exams = [] for exam_type in EXAM_ANSWER_MAP: UserAnswer, Exam = EXAM_ANSWER_MAP[exam_type] exam_answers = UserAnswer.objects.filter(cid=cid) exam_ids = exam_answers.values_list("exam").distinct() logger.debug(f"DEBUG cid_user_overview: {exam_type=}, {cid=}, answers_count={exam_answers.count()}, exam_ids={list(exam_ids)}") exams_to_add = Exam.objects.filter(id__in=exam_ids, **kwargs) logger.debug(f"DEBUG cid_user_overview: {exams_to_add=}") if exams_to_add: exams.append((exam_type, exams_to_add)) case_collections = cid_user.casecollection_exams.all() available_exams = cid_user.get_cid_exams(include_case_collections=False) return render( request, "cid_user_overview.html", { # "physics_exams": physics_exams, # "anatomy_exams": anatomy_exams, # "rapid_exams": rapid_exams, # "longs_exams": longs_exams, # "sba_exams": sba_exams, "all_exams": exams, "cid": cid, "passcode": passcode, "cid_user": cid_user, "available_exams": available_exams, "case_collections": case_collections, }, ) def active_exams(request, cid=None, passcode=None, based=True): active_exams = {} if cid is not None: active_exams["cid"] = cid cid_user = CidUser.objects.filter(cid=cid).first() if not cid_user or cid_user.passcode != passcode: return JsonResponse({"status": "invalid"}, status=401) anatomy_exams = AnatomyExamViews.active_exams( request, False, cid=cid, passcode=passcode ) rapid_exams = RapidsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) short_exams = ShortsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) long_exams = LongsExamsViews.active_exams(request, False, cid=cid, passcode=passcode) exams = [] exams.extend(anatomy_exams) exams.extend(rapid_exams) exams.extend(short_exams) exams.extend(long_exams) active_exams["exams"] = exams if request.user: active_exams["user"] = request.user.username active_exams["user_id"] = request.user.pk else: active_exams["user"] = False return JsonResponse(active_exams) def active_exams_unbased(request, cid=None, passcode=None): return active_exams(request, cid, passcode, based=False) def active_exams_by_type(request, exam_type, cid=None, passcode=None, based=True): active_exams_data = {} if cid is not None: active_exams_data["cid"] = cid cid_user = CidUser.objects.filter(cid=cid).first() if not cid_user or cid_user.passcode != passcode: return JsonResponse({"status": "invalid"}, status=401) exams = [] if exam_type == "anatomy": exams = AnatomyExamViews.active_exams( request, False, cid=cid, passcode=passcode ) elif exam_type == "rapid": exams = RapidsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) elif exam_type == "short": exams = ShortsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) elif exam_type == "long": exams = LongsExamsViews.active_exams( request, False, cid=cid, passcode=passcode ) active_exams_data["exams"] = exams if request.user and request.user.is_authenticated: active_exams_data["user"] = request.user.username active_exams_data["user_id"] = request.user.pk else: active_exams_data["user"] = False return JsonResponse(active_exams_data) def active_exams_by_type_unbased(request, exam_type, cid=None, passcode=None): return active_exams_by_type(request, exam_type, cid, passcode, based=False) @csrf_exempt def exam_submit(request): if request.method == "POST": exam_type, exam_id = request.POST.get("eid").split("/") try: if exam_type == "anatomy": return AnatomyExamViews.post_exam_answers(request) elif exam_type == "rapid": return RapidsExamsViews.post_exam_answers(request) elif exam_type == "long": return LongsExamsViews.post_exam_answers(request) elif exam_type == "short": return ShortsExamsViews.post_exam_answers(request) return JsonResponse({"success": False, "error": "Invalid exam type"}) except ObjectDoesNotExist: return JsonResponse({"success": False, "error": "Invalid data"}) # post_exam_answers return JsonResponse({"success": False, "error": "Invalid data"}) def feedback_create(request, question_type, pk): if question_type == "anatomy": question = AnatomyQuestion elif question_type == "rapid": question = Rapid elif question_type == "long": question = Long return JsonResponse({"success": False, "error": "Invalid exam type"}) class AddQuestionNote(CreateView): model = QuestionNote form_class = QuestionNoteForm # fields = ("author", "note_type", "note") def get_form_kwargs(self): kwargs = super(AddQuestionNote, self).get_form_kwargs() # update the kwargs for the form init method with yours kwargs.update(self.kwargs) # self.kwargs contains all url conf params kwargs.update({"user": self.request.user}) return kwargs def get_initial(self): pk = self.kwargs["pk"] self.question_type = self.kwargs["question_type"] question, content_type = get_question_and_content_type(self.question_type) self.question = get_object_or_404(question, pk=pk) return {"content_type": content_type, "object_id": pk} def get_context_data(self, **kwargs): # This can also be accessed via view.**** in the template context = super(AddQuestionNote, self).get_context_data(**kwargs) context["question"] = self.question context["question_type"] = self.question_type return context def form_valid(self, form): model = form.save(commit=False) if self.request.user.is_authenticated: model.user_author = self.request.user model.save() response = super().form_valid(form) return render(self.request, "feedback_submitted.html") return response def get_success_url(self) -> str: return super().get_success_url() @user_passes_test(lambda u: u.is_superuser) def feedback_note_delete(request, pk): q = get_object_or_404(QuestionNote, pk=pk) q.delete() return redirect(q.get_object_url()) @login_required def feedback_mark_complete(request, pk): q = get_object_or_404(QuestionNote, pk=pk) if ( request.user not in q.question.author.all() and not request.user.groups.filter(name="feedback_checker").exists() ): raise PermissionDenied() q.complete = True q.save() return redirect(q.get_object_url()) @login_required def answer_suggestion_submit(request): """Submit a proposed answer to a question for review. 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"}) post_data = json.loads(request.body) question_type, qid = post_data["qid"].split("/") answer_string = post_data["answer"] status = post_data["status"] if str(status) not in " 012": return JsonResponse({"success": False, "error": "Invalid status"}) 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"}) 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 def answer_submit(request): if request.method == "POST": qid = request.POST.get("qid") question_type = request.POST.get("question_type") answer_string = request.POST.get("answer") status = request.POST.get("status") if str(status) not in "012": return JsonResponse({"success": False, "error": "Invalid status"}) 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"}) 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) a.save() return JsonResponse({"success": True, "error": "Answer added"}) return JsonResponse({"success": False, "error": "Invalid data"}) @login_required def answer_suggestion_confirm(request): if request.method == "POST": question_type = request.POST.get("question_type") aid = request.POST.get("aid") status = request.POST.get("status") 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"}) answer = get_object_or_404(AnswerModel, pk=aid) answer.proposed = False answer.status = status answer.save() return JsonResponse({"success": True, "error": "Answer changed"}) @login_required @user_passes_test(feedback_checker) def view_feedback(request): # exam = get_object_or_404(Exam, pk=pk) feedback_notes = QuestionNote.objects.filter(complete=False) return render( request, "view_feedback.html", { "notes": feedback_notes, }, ) def privacy_view(request): return render(request, "privacy.html") def cimar(request): if "link_id" in request.GET: link_id = request.GET["link_id"] return render(request, "cimar.html", {"link_id": link_id}) return render(request, "cimar.html") def about_view(request): return render(request, "about.html") def stats(request): collection_count = CaseCollection.objects.filter(archive=False).count() case_count = AtlasCase.objects.filter().count() anatomy_exam_count = AnatomyExam.objects.filter(archive=False).count() anatomy_question_count = AnatomyQuestion.objects.filter().count() return render( request, "stats.html", { "collection_count": collection_count, "case_count": case_count, "anatomy_exam_count": anatomy_exam_count, "anatomy_question_count": anatomy_question_count, }, ) # HTTP Error 400 def page_not_found(request, exception): # Provide the exception message (reason) to the template so callers # can show a user-friendly explanation when available. context = {"reason": str(exception) if exception is not None else "Not found"} response = render(request, "404.html", context=context) response.status_code = 404 return response def page_forbidden(request, exception): response = render(request, "403.html", context={"exception": exception}) response.status_code = 403 return response def server_error(request): response = render(request, "500.html") response.status_code = 500 return response class UserListView(CidManagerRequiredMixin, FilterView): model = User template_name = "user_list_view.html" filterset_class = UserListFilter class UserListTableView(CidManagerRequiredMixin, SingleTableMixin, FilterView): model = User table_class = UserUserTable template_name = "generic/user_view.html" filterset_class = UserUserFilter # table_pagination = {"per_page": 5} def get_paginate_by(self, table_data) -> int | None: return 100 # return super().get_paginate_by(table_data) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # user = self.request.user # filters = {"archive": False, "exam_mode": True} # physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] # rapid_exams = [(i.name, i.pk) for i in RapidsExam.objects.filter(**filters)] # sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] # longs_exams = [(i.name, i.pk) for i in LongsExam.objects.filter(**filters)] # anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] # casecollection_exams = [ # (i.name, i.pk) # for i in CaseCollection.objects.filter(archive=False, collection_type__gt=0) # ] # context["physics_exams"] = physics_exams # context["rapid_exams"] = rapid_exams # context["sba_exams"] = sba_exams # context["longs_exams"] = longs_exams # context["anatomy_exams"] = anatomy_exams # context["casecollection_exams"] = casecollection_exams # cid_user_groups = [ # (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) # ] # context["cid_user_groups"] = cid_user_groups return context class DeleteUserView(CidManagerRequiredMixin, DeleteView): model = User template_name: str = "confirm_delete.html" success_url = reverse_lazy("accounts_list") class UpdateUserView(CidManagerRequiredMixin, UpdateView): model = User fields = [ "first_name", "last_name", "email", "is_active", ] # Keep listing whatever fields # the combined UserProfile and User exposes. template_name = "user_update.html" slug_field = "username" slug_url_kwarg = "slug" def get_success_url(self): view_name = "account_profile" # No need for reverse_lazy here, because it's called inside the method return reverse(view_name, kwargs={"slug": self.object.username}) class UpdateUserProfileAdminView(CidManagerRequiredMixin, UpdateView): model = UserProfile fields = [ "supervisor", "grade", "registration_number", "peninsula_trainee", ] # Keep listing whatever fields template_name = "user_update_profile.html" slug_field = "user__username" slug_url_kwarg = "slug" def get_success_url(self): view_name = "account_profile" # No need for reverse_lazy here, because it's called inside the method return reverse(view_name, kwargs={"slug": self.object.username}) class UpdateUserProfileView(UpdateView): model = UserProfile fields = [ "supervisor", "grade", "registration_number", # "peninsula_trainee", ] # Keep listing whatever fields template_name = "user_update_profile.html" slug_field = "user__username" slug_url_kwarg = "slug" def dispatch(self, request, *args, **kwargs): if ( request.user.is_superuser or request.user.groups.filter(name="cid_user_manager").exists() ): self.fields = [ "supervisor", "grade", "registration_number", "site", "peninsula_trainee", ] # Check permissions for the request.user here elif request.user != self.get_object().user: raise Http404 elif request.user.userprofile.peninsula_trainee: self.fields = ["supervisor", "grade", "registration_number", "site"] else: self.fields = ["grade", "registration_number", "site"] return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs: reverse_lazy) -> dict[str, Any]: context = super().get_context_data(**kwargs) form = context["form"] if "supervisor" in form.fields: # form.fields["supervisor"].queryset = Supervisor.objects.all() form.fields["supervisor"] = forms.ModelChoiceField( Supervisor.objects.all().order_by("name"), required=False, widget=autocomplete.ModelSelect2(url="generic:supervisor-autocomplete"), ) return context def get_success_url(self): if "redirect" in self.request.GET: return self.request.GET["redirect"] if ( self.request.user.is_superuser or self.request.user.groups.filter(name="cid_user_manager").exists() ): view_name = "account_profile" # No need for reverse_lazy here, because it's called inside the method return reverse(view_name, kwargs={"slug": self.object.username}) else: return reverse("profile") # class UpdateUser(TemplateView): # # user_form_class = UserForm # comment_form_class = UserProfileForm # template_name = 'user_update.html' # slug_field = 'username' # slug_url_kwarg = 'slug' # # def post(self, request): # post_data = request.POST or None # user_form = self.user_form_class(post_data, prefix='user') # profile_form = self.profile_form_class(post_data, prefix='userprofile') # # context = self.get_context_data(post_form=user_form, # profile_form=profile_form) # # if user_form.is_valid(): # self.form_save(user_form) # if profile_form.is_valid(): # self.form_save(profile_form) # # return self.render_to_response(context) # # def form_save(self, form): # obj = form.save() # #messages.success(self.request, "{} saved successfully".format(obj)) # return obj # # def get(self, request, *args, **kwargs): # return self.post(request, *args, **kwargs) @user_is_cid_user_manager def accounts_check_users(request): if request.method == "POST": users = json.loads(request.POST.get("user_list")) existing_users = [] for email in users: if User.objects.filter(username=email).exists(): existing_users.append(email) return JsonResponse({"users": existing_users}) return @user_is_cid_user_manager def accounts_bulk_create_check(request): if not request.method == "POST": return HttpResponse("Invalid request") user = {} user["email"] = request.POST.get("email") user["first_name"] = request.POST.get("first_name") user["last_name"] = request.POST.get("last_name") user["grade"] = request.POST.get("grade") user["supervisor_email"] = request.POST.get("supervisor_email") user["supervisor_name"] = request.POST.get("supervisor_name") errors = [] info = [] if user["email"] == "undefined": errors.append("No email provided") if User.objects.filter(username=user["email"]).exists(): errors.append("User already exists") if not UserGrades.objects.filter(name=user["grade"]): errors.append("Invalid grade") # Treat empty, missing or the string 'undefined' as no supervisor email provided if not user.get("supervisor_email") or user["supervisor_email"] == "undefined": # Supervisor email not provided; try to match by name if not user.get("supervisor_name"): errors.append("No supervisor provided") else: supervisor = Supervisor.objects.filter(name__iexact=user["supervisor_name"]) if not supervisor.exists(): info.append("Supervisor does not exist (will be created)") else: info.append("Supervisor exists (matched by name)") if supervisor.count() > 1: errors.append("More than one supervisor with that name") # no further name check needed since matched by name else: supervisor = Supervisor.objects.filter(email=user["supervisor_email"]) if not supervisor: info.append("Supervisor does not exist (will be created)") else: info.append("Supervisor exists") if supervisor.count() > 1: errors.append("More than one supervisor with that email") # Compare names case-insensitively and trimmed to avoid false mismatches try: sup_name = (supervisor.first().name or '').strip().lower() posted_name = (user.get("supervisor_name") or '').strip().lower() if posted_name and sup_name != posted_name: errors.append("Supervisor name does not match") except Exception: pass if errors: return HttpResponse( format_html("{}", "
".join(errors)) ) return HttpResponse(format_html("{}", "
".join(info))) @user_is_cid_user_manager def accounts_bulk_create(request): if request.method == "POST": context = {} users = json.loads(request.POST.get("usersjson")) created_users = [] existing_users = [] error_text = "" try: users = json.loads(request.POST.get("usersjson")) for user in users: try: existing_user = User.objects.get(username=user["email"]) existing_users.append(user["email"]) user_profile = UserProfile.objects.get(user=existing_user) # Update grade and supervisor details # This code is dup[licated] if "grade" in user: if not user["grade"].startswith("ST"): user["grade"] = f"ST{user['grade']}" grade = UserGrades.objects.get(name=user["grade"]) user_profile.grade = grade if "supervisor_name" in user and user.get("supervisor_name"): sup_email = user.get("supervisor_email") # If supervisor email provided, prefer that. Otherwise try to match by name or create placeholder if sup_email and sup_email != 'undefined': s, created = Supervisor.objects.get_or_create(email=sup_email, defaults={"name": user.get("supervisor_name")}) else: # try match by name s_qs = Supervisor.objects.filter(name__iexact=user.get("supervisor_name")) if s_qs.exists(): s = s_qs.first() created = False else: import uuid placeholder = f"no-email-{uuid.uuid4().hex}@local.invalid" s = Supervisor.objects.create(email=placeholder, name=user.get("supervisor_name")) created = True if created: s.save() user_profile.supervisor = s user_profile.save() except User.DoesNotExist: # Start by checking the email try: validate_email(user["email"]) except ValidationError: error_text = ( error_text + f"

{user['email']} does not appear to be a valid email (account for {user['first_name']} {user['last_name']} not created).

" ) continue # Try to create the user try: user_dict = { "username": user["email"], "first_name": user["first_name"], "last_name": user["last_name"], "email": user["email"], "password": secrets.token_hex(nbytes=16), } new_user = User.objects.create_user(**user_dict) except Exception as error: error_text = ( error_text + f"

Error creating user: {user['email']}
{error}

" ) # No need to try and create a profile if we can't create a user continue try: user_profile = UserProfile.objects.get(user=new_user) user_profile.peninsula_trainee = True # TODO: check supervisor details are correct if "supervisor_name" in user and user.get("supervisor_name"): sup_email = user.get("supervisor_email") if sup_email and sup_email != 'undefined': s, created = Supervisor.objects.get_or_create(email=sup_email, defaults={"name": user.get("supervisor_name")}) else: s_qs = Supervisor.objects.filter(name__iexact=user.get("supervisor_name")) if s_qs.exists(): s = s_qs.first() created = False else: import uuid placeholder = f"no-email-{uuid.uuid4().hex}@local.invalid" s = Supervisor.objects.create(email=placeholder, name=user.get("supervisor_name")) created = True if created: s.save() user_profile.supervisor = s if "grade" in user: if not user["grade"].startswith("ST"): user["grade"] = f"ST{user['grade']}" grade = UserGrades.objects.get(name=user["grade"]) user_profile.grade = grade user_profile.save() created_users.append(user["email"]) except Exception as error: # If we fail to create the profile delete the user new_user.delete() error_text = ( error_text + f"

Error creating user profile: {user['email']}
{error} (user not created)

" ) except Exception as error: error_text = error_text + f"

Error creating users.
{error}

" context["error"] = mark_safe(error_text) context["created_users"] = created_users if existing_users: context["message"] = "The following users already exist: " + ", ".join( existing_users ) return render(request, "accounts_bulk_create.html", context) else: return render(request, "accounts_bulk_create.html", {}) def request_cid_details(request): email = request.POST.get("email") try: validate_email(email) except ValidationError: return HttpResponse("Invalid email.") cid_users = CidUser.objects.filter(email=email) for cid_user in cid_users: cid_user.email_details(resend=True) cid_user.email_details() return HttpResponse( f"Candidate details will be sent to the requested email (if it is found in the system). If you have not received an email after a few minutes you may need to contact {settings.CONTACT_EMAIL}" ) def cimar_study_dicom_json(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): with CimarAPI(request.user.userprofile.cimar_sid) as api: # studies = api.list_studies() # test_study = studies["studies"][0] # schema = api.get_study_schema(test_study) schema = api.get_study_schema_by_uuid("c9417b53-87aa-4c40-aca0-3fa34c225536") return JsonResponse(api.get_study_dicom_json(schema)) def cimar_study_viewer(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): case = get_object_or_404(CimarCase, uuid=uuid) viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" return render(request, "cimar_embed.html", {"viewer_link": viewer_link}) def cimar_study_viewer_embed(request, uuid="c9417b53-87aa-4c40-aca0-3fa34c225536"): case = get_object_or_404(CimarCase, uuid=uuid) viewer_link = case.viewer_link + f"?sid={request.user.userprofile.cimar_sid}" return render(request, "cimar_embed.html#embed", {"viewer_link": viewer_link}) def cimar_study_series_block(request, uuid): case = get_object_or_404(CimarCase, uuid=uuid) return render( request, "cimar_series.html", { "series_data": case.series_data["series"], "cimar_sid": request.user.userprofile.cimar_sid, }, ) def cimar_status(request): login_status = CimarAPI(request.user.userprofile.cimar_sid).check_login_status() if login_status: return HttpResponse("") else: return render( request, "cimar_login.html#login-block", { "login_status": login_status, "cimar_sid": request.user.userprofile.cimar_sid, }, ) def cimar_logout(request): CimarAPI(request.user.userprofile.cimar_sid).logout() return HttpResponse("Logged out") def cimar_login(request): if request.htmx: username = request.POST.get("username") password = request.POST.get("password") if not username and not password: return HttpResponse("Please fill in login details") try: sid = CimarAPI().login(username, password) request.user.userprofile.cimar_sid = sid request.user.userprofile.save() except InvalidCredentials: return HttpResponse("Invalid login details") return HttpResponse("Logged in") else: try: login_status = CimarAPI( request.user.userprofile.cimar_sid ).check_login_status() except NoSession: login_status = False return render( request, "cimar_login.html", { "login_status": login_status, "cimar_sid": request.user.userprofile.cimar_sid, }, )