from collections import defaultdict
import datetime
import json
import os
from typing import Optional, Self, Tuple
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.functions import Lower
from django.forms import ValidationError
from django.http import Http404, HttpRequest
from django.shortcuts import render
from django.utils import timezone
from django.core.validators import MaxValueValidator, MinValueValidator
from smtplib import SMTPException
from django.urls import reverse
from django.views.generic.edit import CreateView
from reversion.views import RevisionMixin
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.mail import send_mail
from django.core.mail import EmailMessage
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
from generic.mixins import AuthorMixin, QuestionMixin
from helpers.images import get_image_hash, pretty_print_dicom
from helpers.cimar import CimarAPI, STORAGE_API_URL
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from django.utils.html import format_html
from easy_thumbnails.files import get_thumbnailer
from easy_thumbnails.exceptions import InvalidImageFormatError
import pydicom
import pydicom.errors
import dicognito.anonymizer
from django.contrib.auth.models import User
from django.forms.models import model_to_dict
from django.forms.utils import from_current_timezone, to_current_timezone
from loguru import logger
from pygments import highlight
from pygments.lexers import JsonLexer
from pygments.formatters import HtmlFormatter
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string
import requests
from django.core.files.base import ContentFile
USER_EXAM_TYPES = (
("Physics", "user_physics_exams"),
("Rapids", "user_rapid_exams"),
("Shorts", "user_shorts_exams"),
("SBAs", "user_sba_exams"),
("Anatomy", "user_anatomy_exams"),
("Longs", "user_longs_exams"),
# ("CaseCollection", "user_casecollection_exams"),
)
def get_pretty_json(data):
"""Function to display pretty version of our data"""
if not data:
return "No data"
# Convert the data to sorted, indented JSON
response = json.dumps(data, sort_keys=True, indent=2)
# Get the Pygments formatter
formatter = HtmlFormatter(style='colorful')
# Highlight the data
response = highlight(response, JsonLexer(), formatter)
# Get the stylesheet
style = "
"
# Safe the output
return mark_safe(style + response)
def findMiddle(input_list):
"""Returns the middle element of a list"""
middle = float(len(input_list)) / 2
if middle % 2 != 0:
return input_list[int(middle - 0.5)]
else:
return input_list[int(middle)]
class Modality(models.Model):
modality = models.CharField(max_length=200, unique=True)
short_code = models.CharField(max_length=5, unique=True, null=True)
def __str__(self):
return f"{self.modality} ({self.short_code})"
class Plane(models.Model):
plane = models.CharField(max_length=200, unique=True)
def __str__(self):
return self.plane
class Meta:
ordering = ("plane",)
class Contrast(models.Model):
contrast = models.CharField(max_length=200, unique=True)
def __str__(self):
return self.contrast
class Meta:
ordering = ("contrast",)
class Examination(models.Model):
"""
Currently used for
- Atlas series
- Longs series
- Anatomy questions
Not used for rapids - yet??
"""
examination = models.CharField(max_length=200, unique=True)
modality = models.ForeignKey(Modality, on_delete=models.SET_NULL, null=True)
class Meta:
ordering = ("examination",)
def __str__(self):
return self.examination
def merge_into(self, examination_to_merge_into: Self, delete: bool = True) -> bool:
"""Merges the current examination into another
To do so it replaces all associations (many to many and foreign key relations)
Returns True if successful
"""
# Get all atlas series with the examination
atlas_series = self.atlas_series_examination.all()
longs_series = self.series_examination.all()
anatomy_questions = self.anatomyquestion_set.all()
# Reassign them to the new object
for series in atlas_series:
series.examination = examination_to_merge_into
series.save()
for series in longs_series:
series.examination = examination_to_merge_into
series.save()
for question in anatomy_questions:
question.examination = examination_to_merge_into
question.save()
# quick check to make sure nothing has gone wrong and we have hanging
# associations
querysets = (
self.atlas_series_examination.all(),
self.series_examination.all(),
self.anatomyquestion_set.all(),
)
for q in querysets:
if q.count() > 0:
return False
if delete:
self.delete()
return True
class Site(models.Model):
"""Model to hold site details"""
full_name = models.CharField(
max_length=255, blank=True, help_text="Name of the site"
)
short_code = models.CharField(
max_length=255, blank=True, help_text="Shortcode/name of the site"
)
def __str__(self):
return self.short_code
class QuestionBase(models.Model, AuthorMixin, QuestionMixin):
authors_only = models.BooleanField(
help_text="If true only question authors will be able to view.",
default=False,
)
created_date = models.DateTimeField(default=timezone.now)
open_access = models.BooleanField(
help_text="If a question should be freely available to browse", default=True
)
feedback = models.TextField(null=True, blank=True, help_text="Question Feedback")
notes = GenericRelation("generic.QuestionNote")
reviews = GenericRelation("generic.QuestionReview")
class Meta:
abstract = True
def get_unanswered_mark_and_text(self) -> tuple[int, str]:
"""
Override in models if needed
"""
return (0, "Not answered")
def get_primary_answer(self):
"""If this makes sense in the question/answer context override"""
return None
def can_edit(self, user: User) -> bool:
"""Returns True if the user can edit the question"""
if user.is_superuser:
return True
if user in self.get_author_objects():
return True
return False
def get_thumbnail(self, recreate=False, fail_loudly=False):
images = self.images.all()
if len(images) < 1:
return "No images", 0
img = findMiddle(images).image
try:
thumbnailer = get_thumbnailer(img)
if recreate:
thumbnailer.delete_thumbnails()
thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError as e:
if fail_loudly:
raise e
return format_html(
'
', img
), len(images)
return format_html('', thumbnail), len(images)
class SeriesImageBase(models.Model):
"""
Series Images may be modified (usually downsampled to save space), in this case the
(larger) original file will be deleted but the model object will be persisted so that
the duplicate check continues to function.
"""
position = models.IntegerField(default=0)
upload_filename = models.CharField(max_length=255, blank=True)
series = models.ForeignKey(
"Series", related_name="images", on_delete=models.SET_NULL, null=True
)
dicom_tags_ohif = models.JSONField(
help_text="Holds the dicom tags required for the OHIF viewer",
blank=True,
null=True,
)
# We store two hashes of the dicom data to uniquely identify the file
# and allow duplicate detection
# The first is a md5 hash of the pixel array data
# this is used because I can't get the direct pixel data to match from cornerstone
# (and struggle to get blake3 working in the browser)
image_md5_hash = models.CharField(max_length=64, null=True, blank=True)
# The second is a blake3 hash of the direct pixel data
# This is much faster when using pydicom
image_blake3_hash = models.CharField(max_length=64, null=True, blank=True)
is_dicom = models.BooleanField(default=False)
removed = models.BooleanField(
default=False,
help_text="Set to true if the file this object refers to has been removed (either from series truncation or merging)",
)
class Meta:
ordering = ["position"]
abstract = True
def get_file_size(self):
try:
return self.image.size
# We catch this for when the image does not exist
except FileNotFoundError:
return 0
def get_dicom_data(self):
try:
with pydicom.dcmread(self.image) as d:
return d
except pydicom.errors.InvalidDicomError:
return {}
def get_series(self):
return self.series
def get_dicom_json(self):
try:
json = pydicom.dcmread(self.image).to_json()
except pydicom.errors.InvalidDicomError:
return {}
return json
def get_dicom_info(self):
try:
info = pretty_print_dicom(pydicom.dcmread(self.image))
except pydicom.errors.InvalidDicomError:
info = "File is not a dicom."
return info
def generate_md5_hash(self):
if not self.removed:
image_hash, is_dicom = get_image_hash(
self.image, hash_type="md5", direct_pixel_data=False
)
self.is_dicom = is_dicom
self.image_md5_hash = image_hash
self.save()
def generate_blake3_hash(self):
if not self.removed:
image_blake3_hash, is_dicom = get_image_hash(
self.image, hash_type="blake3", direct_pixel_data=True
)
self.is_dicom = is_dicom
self.image_blake3_hash = image_blake3_hash
self.save()
def save(self, *args, **kwargs):
"""Override save method to add image hash"""
if self.image:
# if not self.image_md5_hash:
# image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False)
# self.is_dicom = is_dicom
# self.image_md5_hash = image_hash
if not self.image_blake3_hash:
image_blake3_hash, is_dicom = get_image_hash(
self.image, hash_type="blake3", direct_pixel_data=True
)
self.is_dicom = is_dicom
self.image_blake3_hash = image_blake3_hash
## Hack for tests
# if image_hash != "12345ABCD":
# super().save(*args, **kwargs) # Call the "real" save() method.
super().save(*args, **kwargs) # Call the "real" save() method.
class SeriesBase(models.Model, AuthorMixin):
info = models.TextField(
blank=True,
help_text="Description of stack, for admin organisation, will not be visible when taking",
)
description = models.CharField(
null=True,
blank=True,
max_length=255,
help_text="Description of the series. This is usually visable to the user (as the name of the stack)",
)
open_access = models.BooleanField(
help_text="If a series should be freely available to browse", default=True
)
created_date = models.DateTimeField(auto_now_add=True)
modified_date = models.DateTimeField(auto_now=True)
class SeriesModifiedChocies(models.TextChoices):
NO = (
"NO",
"Not modified",
)
TR = (
"TR",
"Truncated",
)
RE = (
"RE",
"Resliced/resampled",
)
modified = models.CharField(
max_length=2,
default=SeriesModifiedChocies.NO,
choices=SeriesModifiedChocies.choices,
)
total_image_size = models.BigIntegerField(null=True, blank=True, help_text="Stores a (cached) total size of images within the series")
class Meta:
abstract = True
def __str__(self):
return f"{self.pk}:{self.description}"
def check_user_can_edit(self, user: User):
if user.is_superuser:
return True
if user in self.get_author_objects():
return True
return False
def get_examination(self):
"""Returns a comma seperated text list of regions"""
return str(self.examination)
def get_examination_full(self):
examination = ""
plane = ""
contrast = ""
if self.examination:
examination = self.examination
if self.plane:
plane = " {}".format(self.plane)
if self.contrast:
contrast = " {}".format(self.contrast)
return "{}{}{}".format(examination, plane, contrast)
def get_images(self, include_removed=False):
if include_removed:
return self.images.all()
else:
return self.images.filter(removed=False)
def get_image_count(self):
return self.images.filter(removed=False).count()
def get_image_urls(self):
images = [
f"{REMOTE_URL}{i.image.url}" for i in self.images.filter(removed=False)
]
return ",".join(images)
def get_image_url_array_not_json(self):
return self.get_image_url_array(json_output=False)
def get_image_url_array_and_count(self, json_output=True):
return self.get_image_url_array(json_output=json_output, count=True)
def get_image_url_array(self, json_output=True, count=False):
images = [
f"{REMOTE_URL}{i.image.url}" for i in self.images.filter(removed=False)
]
if json_output:
if count:
return (json.dumps(images), len(images))
else:
return json.dumps(images)
else:
# This is a mess...
if count:
return (format_html('", "'.join(images)), len(images))
else:
return format_html('", "'.join(images))
def get_thumbnail(self, recreate=False, fail_loudly=False):
# Use prefetched images if available
images = getattr(self, '_prefetched_objects_cache', {}).get('images')
if images is not None:
images = [img for img in images if not img.removed]
else:
images = self.images.filter(removed=False)
if len(images) < 1:
return "No images", 0
img = findMiddle(images).image
try:
thumbnailer = get_thumbnailer(img)
if recreate:
thumbnailer.delete_thumbnails()
thumbnail = thumbnailer["exam-list"]
except InvalidImageFormatError as e:
if fail_loudly:
raise e
return format_html(
'
', img
), len(images)
return format_html('', thumbnail), len(images)
def get_thumbnail_link(self):
return format_html(
"{}", self.get_absolute_url(), self.get_thumbnail()[0]
)
def get_block(self, series_number: None | int = None):
examination = self.get_examination_full()
thumb, image_number = self.get_thumbnail()
series_html = ""
if series_number is not None:
series_html = format_html(
"Series {}{}
",
series_number,
)
description = ""
if self.description:
description = format_html("{}
", self.description)
return format_html(
"""{}{}
{}
{}
Images: {}
""",
series_html,
description,
examination,
thumb,
image_number,
)
def order_by_upload_filename(self):
images = self.images.filter(removed=False)
filenames = []
map = {}
for i in images:
filenames.append(i.upload_filename)
map[i.upload_filename] = i
filenames = sorted(filenames)
n = 1
for f in filenames:
i = map[f]
i.position = n
i.save()
n = n + 1
def order_by_dicom(self, field="SliceLocation"):
"""
Orders images in the series by the given DICOM field (default: SliceLocation).
Only keeps minimal data in memory and uses bulk_update for efficiency.
"""
images = self.images.filter(removed=False)
sortable = []
for img in images:
try:
ds = pydicom.dcmread(img.image.path, stop_before_pixels=True)
value = getattr(ds, field, None)
if value is not None:
sortable.append((value, img))
except Exception:
continue # Skip unreadable or invalid DICOMs
if not sortable:
return
# Sort by the DICOM field value
sortable.sort(key=lambda x: x[0])
# Assign new positions and collect for bulk update
for idx, (value, img) in enumerate(sortable, start=1):
img.position = idx
# Bulk update all changed images
SeriesImage = self.images.model
SeriesImage.objects.bulk_update([img for _, img in sortable], ['position'])
def get_total_image_size(self, refresh=False):
"""Returns the total size of all images in the series.
If refresh is set to True the size will be recalculated and saved to the database
(in the field 'total_image_size')."""
if not refresh and self.total_image_size:
return self.total_image_size
images = self.images.filter(removed=False)
size = 0
for i in images:
size += i.get_file_size()
self.total_image_size = size
self.save()
return size
def anonymise_images(self):
# NOTE: this will not maintain the correct hashed filename
# but that doesn't matter as we will never get the same anonymisation
# even with the same dicom...
anonymizer = dicognito.anonymizer.Anonymizer()
for series_image in self.images.filter(removed=False):
file_path = os.path.join(settings.MEDIA_ROOT, series_image.image.name)
try:
with pydicom.dcmread(file_path) as dataset:
anonymizer.anonymize(dataset)
dataset.save_as(file_path)
except pydicom.errors.InvalidDicomError:
pass
class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
"""Holds functions that relate to both case and other exams
e.g. user management
"""
# Is this actually used? It is but should be replaced on the inherited class
cid_users = GenericRelation("generic.CidUserExam")
name = models.CharField(max_length=200, help_text="Name of the exam/collection")
created = models.DateTimeField(auto_now_add=True, help_text="Creation timestamp")
updated = models.DateTimeField(auto_now=True, help_text="Last updated timestamp")
start_date = models.DateTimeField(
help_text="Date (and time) the exam/collection starts", null=True, blank=True
)
end_date = models.DateTimeField(
help_text="Date (and time) the exam/collection ends", null=True, blank=True
)
restrict_to_dates = models.BooleanField(
default=False,
help_text="If the exam/collection should only be taken between the start and end date times",
)
active = models.BooleanField(
help_text="If an exam/collection should be available to take", default=False
)
publish_results = models.BooleanField(
help_text="If an exam/collections results should be available", default=False
)
archive = models.BooleanField(
help_text="Archived exams/collections will remain on the test system but will not be displayed by default",
default=False,
)
open_access = models.BooleanField(
help_text="If the exam/collection is freely accessible (to view and edit on the test system)",
default=False,
)
candidates_only = models.BooleanField(
help_text="If the exam/collection is only available to candidates who have been added",
default=True,
)
authors_only = models.BooleanField(
help_text="If true only exam/collection authors will be able to view.",
default=False,
)
results_supervisor_visible = models.BooleanField(
help_text="If true the results will be visible to supervisors without candidate approval.",
default=False,
)
exam_open_access = models.BooleanField(default=False, help_text="Set to true if you want any registered user to be able to take the exam.")
class Meta:
abstract = True
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):
if self.restrict_to_dates:
if self.start_date is None:
raise ValidationError(
"If restrict to dates is set, a start date must be set"
)
if self.end_date is not None and self.end_date <= self.start_date:
raise ValidationError({"end_date": "End date must be after start date"})
return super().clean(*args, **kwargs)
def get_app_name(self):
raise NotImplementedError(
"You must implement get_app_name in the derived class"
)
def get_base_template(self):
return f"{self.get_app_name()}/exams.html"
def get_link_headers(self):
return "generic/exam_link_headers.html"
def check_user_can_edit(self, user: User):
if user.is_superuser:
return True
if user in self.get_author_objects():
return True
return False
def check_logged_in_user(self, request: HttpRequest):
"""Helper to check if the logged in user can access the exam"""
return self.check_cid_user(user=request.user, user_id=request.user.id)
def check_user_can_review(
self, cid, passcode, request: HttpRequest, active_only=False
):
return self.check_user_can_take(cid, passcode, request.user, active_only)
def check_user_can_take(self, cid, passcode, user=None, active_only=True):
"""
Helper to check if a user is allowed to access an exam/collection
Args:
cid (_type_): _description_
passcode (_type_): _description_
request (_type_): _description_
Raises:
Http404: If user does not have access
"""
if user is not None and self.is_author(user):
return
# If we are limiting by dates check that the current date is within the range
if self.restrict_to_dates:
if self.start_date >= timezone.now() or (
self.end_date is not None and self.end_date <= timezone.now()
):
# Raise with an explanatory message so the frontend can
# surface the reason to the end user.
raise Http404("Exam not currently available (outside allowed dates)")
# If it is not active no one can take
elif active_only and not self.active:
raise Http404("Exam currently inactive")
# If candidates only check they have access
if self.candidates_only:
if not self.check_cid_user(cid, passcode, user):
raise Http404("Error accessing exam")
return
# Otherwise check that they are a valid user.
if user is None:
raise Http404("Error accessing exam")
if not user.is_active:
raise Http404("Error accessing exam")
return
def check_cid_user(
self,
cid: int | None = None,
passcode: str | None = None,
user: User | None = None,
# request: HttpRequest | None = None,
user_id: int | None = None,
allow_authors: bool = True,
):
"""Checks if a user (cid or otherwise) is allowed to access (and take) the exam"""
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}, {user=}, {user_id=}, {allow_authors=}")
if user is not None and user.is_superuser:
return True
if user_id is not None:
if allow_authors:
if self.author.filter(pk=user_id).exists():
return True
if cid is None and user_id is None:
if user.is_anonymous:
return False
if user is not None:
user_id = user.id
else:
return False
if self.exam_open_access and user_id is not None:
return True
if not self.valid_cid_users.exists() and not self.valid_user_users.exists():
return False
# Start by checking if the logged in user can access
if user_id is not None:
if self.valid_user_users.filter(pk=user_id).exists():
return True
# Then test CID data
if self.valid_cid_users.exists():
cid_user = self.valid_cid_users.filter(cid=cid).first()
if cid_user and cid_user.passcode == passcode:
return True
return False
return False
def get_or_create_cid_user_exam(
self,
cid: int | None = None,
cid_user: "CidUser | None" = None,
user_user: User | None = None,
start_time=None,
):
content_type = ContentType.objects.get_for_model(self)
if cid is not None:
cid_user = CidUser.objects.filter(cid=cid).first()
if cid_user is not None:
c = CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
).first()
if c:
return c
elif user_user is not None:
c = CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user=user_user
).first()
if c:
return c
else:
raise ValueError("Invalid cid / cid_user / user_user")
if start_time is None:
start_time = timezone.now()
if cid_user is not None:
new = CidUserExam(
content_type=content_type,
object_id=self.pk,
cid_user=cid_user,
start_time=start_time,
)
elif user_user is not None:
new = CidUserExam(
content_type=content_type,
object_id=self.pk,
user_user=user_user,
start_time=start_time,
)
else:
raise ValueError("Invalid cid / cid_user / user_user")
new.save()
return new
def get_question_cid_user_answer(self, question_index, cid):
raise NotImplementedError
def get_question_user_user_answer(self, question_index, user):
raise NotImplementedError
def get_cid_and_user_exams(
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None:
return CidUserExam.objects.filter(
content_type=content_type,
object_id=self.pk,
)
else:
if cid_user is not None:
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
)
else:
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user=user_user
)
def get_cid_exams(self, cid_user: Optional["CidUser"]= None) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if cid_user is None:
# Only return pure CID entries (cid_user set, user_user null)
return CidUserExam.objects.filter(
content_type=content_type,
object_id=self.pk,
cid_user__isnull=False,
user_user__isnull=True,
)
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, cid_user=cid_user
)
def get_user_exams(self, user_user: Optional[User] = None) -> "CidUserExam":
"""Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if user_user is None:
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user__isnull=False, cid_user__isnull=True
)
return CidUserExam.objects.filter(
content_type=content_type, object_id=self.pk, user_user=user_user
)
def clone_model(self):
M2M_fields = (
"exam_questions",
"author",
"valid_cid_users",
"valid_user_users",
"cid_user_groups",
"user_user_groups",
)
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]
fk_to_update = {}
for field in FK_fields:
fk_to_update[field] = model_dict[field]
del model_dict[field]
cloned_model = self.__class__(**model_dict)
cloned_model.pk = None
for field in fk_to_update:
setattr(cloned_model, f"{field}_id", fk_to_update[field])
cloned_model.save()
for field in m2m_to_update:
rel = getattr(cloned_model, field)
rel.clear()
for item in m2m_to_update[field]:
rel.add(item)
return cloned_model
def add_markers(self, users):
self.markers.add(*users)
def add_marker(self, user):
self.markers.add(user)
def get_candidate_count(self):
return self.valid_cid_users.count() + self.valid_user_users.count()
class ExamBase(ExamOrCollectionGenericBase):
exam_mode = models.BooleanField(
help_text="If an exam should be taken in exam mode (users results will be submited to the server for marking)",
default=False,
)
# randomise_question_order = models.BooleanField(
# help_text="If an exam should randomise the order of questions in RTS.",
# )
include_history = models.BooleanField(
help_text="If an exam should include history when taking",
default=False,
)
recreate_json = models.BooleanField(
help_text="If the json cache needs updating", default=False
)
json_creation_time = models.DateTimeField(blank=True, default=None, null=True)
exam_json_id = models.IntegerField(
default=1, help_text="auto incrementing field when json recreated"
)
stats_mean = models.FloatField(default=0)
stats_mode = models.CharField(default=0, max_length=25)
stats_median = models.FloatField(default=0)
stats_candidates = models.FloatField(default=0)
stats_min = models.FloatField(default=0)
stats_max = models.FloatField(default=0)
stats_max_possible = models.FloatField(default=0)
stats_graph = models.TextField(default=0)
user_scores = models.JSONField(default=dict, blank=True)
exam_results_emailed = models.DateTimeField(default=None, null=True, blank=True)
class Meta:
abstract = True
def __str__(self):
if self.start_date and self.start_date is not None:
print(self.start_date)
print(to_current_timezone(self.start_date))
return f"{self.name} ({to_current_timezone(self.start_date):%d %B %Y})"
else:
return self.name
def save(self, *args, recreate_json=True, **kwargs):
self.recreate_json = recreate_json
super().save(*args, **kwargs)
# def clean(self, *args, **kwargs):
# if self.user_scores is None:
# self.user_scores = {}
# return super().clean(*args, **kwargs)
def get_questions(self):
"""Returns a list of questions in the exam in the correct order
This should be used in preference to exam_questions.all() as it
will order the questions correctly"""
return self.exam_questions.all().order_by("examquestiondetail__sort_order")
def order_questions(self):
"""Modifies the examquestiondetail sort_order to sequentially order the cases"""
for n, c in enumerate(self.examquestiondetail_set.all().order_by("sort_order")):
c.sort_order = n
c.save()
def get_exam_stats(self, name=True):
if self.stats_candidates < int(4):
return f"- Candidates: {int(self.stats_candidates)} (too few to generate stats)"
text = f"""- Candidates: {int(self.stats_candidates)}
- Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}]
"""
if name:
text = f"""{self.name}
{"-"*len(self.name)}
{text}
"""
return text
def get_absolute_url(self):
return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk})
def get_cid_edit_url(self):
return reverse(
"{}:exam_cids_edit".format(self.app_name), kwargs={"exam_id": self.pk}
)
def get_user_edit_url(self):
return reverse(
"{}:exam_users_edit".format(self.app_name), kwargs={"exam_id": self.pk}
)
def get_take_url(self):
return f"{settings.REMOTE_URL}/rts"
def get_exam_name(self):
return str(self)
def get_json_url(self, cid=None, passcode=None):
if cid is None:
return reverse("{}:exam_json".format(self.app_name), args=(self.pk,))
else:
return reverse(
"{}:exam_json_cid".format(self.app_name), args=(self.pk, cid, passcode)
)
def get_time_limit(self):
"""Returns a human readable time limit"""
if self.time_limit is None:
return "No time limit set"
h, m, s = str(datetime.timedelta(seconds=self.time_limit)).split(":")
time_limit = ""
if s != "0" and s != "00":
time_limit = f" {s} second(s)"
if m != "0":
time_limit = f"{m} minute(s) {time_limit}"
if h != "0":
time_limit = f"{h} hour(s) {time_limit}"
return time_limit.strip()
def get_question_index(self, question):
return list(self.get_questions()).index(question)
def get_question_by_index(self, index: int):
# There must be a better way to do this
return list(self.get_questions())[index]
def get_cid_user_score(self, cid_user):
c = "c/" + str(cid_user)
if c in self.user_scores:
return self.user_scores[c]
else:
return False
def get_user_users_with_scores(self):
user_ids = [i[2:] for i in self.user_scores if i.startswith("u/")]
return User.objects.filter(pk__in=user_ids)
def generate_user_report(self, user):
exam_text = [f"Candidate {user.first_name} [{user.email}]"]
exam_name = self.name
exam_text.append(f"{'='*len(exam_name)}\n{exam_name}\n{'='*len(exam_name)}")
try:
user_score = self.user_scores["u/" + str(user.pk)]["score"]
if self.app_name == "rapids":
callstate = self.user_scores["u/" + str(user.pk)]["callstates"]
except KeyError:
user_score = "Score not generated"
try:
percentage = f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
except TypeError:
percentage = ""
exam_text.append(
f"""Results\n{'-'*len('Results')}
# Score: {user_score} / {int(self.stats_max_possible)}{percentage}
"""
)
stats = self.get_exam_stats(name=False)
exam_text.append(f"Stats\n{'-'*len('Stats')}\n{stats}")
rapids_extra = ""
if self.app_name == "rapids":
rapids_extra = f"Answers breakdown: {callstate}"
exam_text.append(rapids_extra)
msg = "\n\n".join(exam_text)
html_msg = f"""
Candidate: {user.first_name} [{user.email}]