.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2023-06-12 09:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0049_alter_condition_parent_alter_condition_synonym_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='series',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Description of stack, for admin organisation, will not be visible when taking'),
|
||||
),
|
||||
]
|
||||
+10
-181
@@ -10,8 +10,6 @@ from django.utils import timezone
|
||||
|
||||
|
||||
import pydicom
|
||||
import dicognito.anonymizer
|
||||
|
||||
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.conf import settings
|
||||
@@ -43,13 +41,11 @@ from generic.models import (
|
||||
Plane,
|
||||
Contrast,
|
||||
QuestionNote,
|
||||
SeriesBase,
|
||||
)
|
||||
|
||||
# from generic.models import Examination, Site, Condition, Sign
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
import pydicom.errors
|
||||
import datetime
|
||||
from django.utils import timezone
|
||||
@@ -343,7 +339,6 @@ class SeriesImage(models.Model):
|
||||
|
||||
|
||||
class SeriesFinding(models.Model):
|
||||
|
||||
series = models.ForeignKey(
|
||||
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
@@ -364,7 +359,7 @@ class SeriesFinding(models.Model):
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Series(models.Model):
|
||||
class Series(SeriesBase):
|
||||
modality = models.ForeignKey(
|
||||
Modality,
|
||||
related_name="atlas_series_modality",
|
||||
@@ -394,10 +389,6 @@ class Series(models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of stack",
|
||||
)
|
||||
|
||||
author = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -407,13 +398,6 @@ class Series(models.Model):
|
||||
|
||||
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
||||
|
||||
open_access = models.BooleanField(
|
||||
help_text="If a series should be freely available to browse", default=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk}:{self.description}"
|
||||
|
||||
def get_full_str(self):
|
||||
if self.case:
|
||||
case_id = ", ".format([case.pk for case in self.case.all()])
|
||||
@@ -424,167 +408,10 @@ class Series(models.Model):
|
||||
self.pk, self.get_examination_full(), self.description, case_id
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("series:question_detail", kwargs={"pk": self.pk})
|
||||
|
||||
# def get_string(self):
|
||||
# examination = self.get_examination_full()
|
||||
# return
|
||||
|
||||
def get_author_objects(self):
|
||||
"""Returns a comma seperated text list of authors"""
|
||||
if self.author:
|
||||
return self.author.all()
|
||||
else:
|
||||
return ["None"]
|
||||
|
||||
def get_author_display(self):
|
||||
return ", ".join([i.username for i in self.get_author_objects()])
|
||||
|
||||
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_absolute_url(self):
|
||||
return reverse("atlas:series_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_image_urls(self, findings=False):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
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(self, findings=False, json_output=True):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
if json_output:
|
||||
return json.dumps(images)
|
||||
else:
|
||||
# This is a mess...
|
||||
return format_html('", "'.join(images))
|
||||
|
||||
def get_thumbnail(self):
|
||||
images = self.images.all()
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
|
||||
img = findMiddle(images).image
|
||||
try:
|
||||
thumbnailer = get_thumbnailer(img)
|
||||
thumbnail = thumbnailer["exam-list"]
|
||||
except InvalidImageFormatError:
|
||||
return format_html('<span title="{}">Invalid image url<span>', img), len(
|
||||
images
|
||||
)
|
||||
return format_html('<img src="/media/{}" />', thumbnail), len(images)
|
||||
|
||||
def get_thumbnail_link(self):
|
||||
return format_html(
|
||||
"<a href='{}'>{}<a/>", self.get_absolute_url(), self.get_thumbnail()[0]
|
||||
)
|
||||
|
||||
def get_block(self):
|
||||
examination = self.get_examination_full()
|
||||
thumb, image_number = self.get_thumbnail()
|
||||
return format_html(
|
||||
"<div>{}<br/>{}<br/>Images: {}</div>", examination, thumb, image_number
|
||||
)
|
||||
|
||||
def order_by_upload_filename(self):
|
||||
images = self.images.all()
|
||||
|
||||
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"):
|
||||
images = self.images.all()
|
||||
|
||||
files = []
|
||||
|
||||
for i in images:
|
||||
files.append((i, pydicom.dcmread(i.image.path)))
|
||||
|
||||
# print("file count: {}".format(len(files)))
|
||||
|
||||
# skip files with no SliceLocation (eg scout views)
|
||||
slices = []
|
||||
map = {}
|
||||
skipcount = 0
|
||||
for i, f in files:
|
||||
if hasattr(f, field):
|
||||
slices.append(f)
|
||||
# map[f.SliceLocation] = i
|
||||
map[f[field].value] = i
|
||||
else:
|
||||
skipcount = skipcount + 1
|
||||
|
||||
print("skipped, no {}: {}".format(field, skipcount))
|
||||
|
||||
# ensure they are in the correct order
|
||||
slices = sorted(slices, key=lambda s: s[field].value)
|
||||
|
||||
# print(slices)
|
||||
n = 1
|
||||
for f in slices:
|
||||
i = map[f[field].value]
|
||||
i.position = n
|
||||
i.save()
|
||||
n = n + 1
|
||||
|
||||
def get_total_image_size(self):
|
||||
images = self.images.all()
|
||||
|
||||
size = 0
|
||||
|
||||
for i in images:
|
||||
size += i.image.size
|
||||
|
||||
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.all():
|
||||
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 CaseCollection(models.Model):
|
||||
@@ -647,8 +474,11 @@ class CaseCollection(models.Model):
|
||||
CidUser, blank=True, related_name="casecollection_exams"
|
||||
)
|
||||
|
||||
cid_user_groups = models.ManyToManyField(CidUserGroup, blank=True, help_text="These groups define which candidates are able to be added to the exams/collection.")
|
||||
|
||||
cid_user_groups = models.ManyToManyField(
|
||||
CidUserGroup,
|
||||
blank=True,
|
||||
help_text="These groups define which candidates are able to be added to the exams/collection.",
|
||||
)
|
||||
|
||||
archive = models.BooleanField(default=False)
|
||||
exam_mode = models.BooleanField(default=False)
|
||||
@@ -693,7 +523,6 @@ class CaseCollection(models.Model):
|
||||
return True
|
||||
|
||||
if self.valid_cid_users.exists():
|
||||
|
||||
user = self.valid_cid_users.filter(cid=cid).first()
|
||||
|
||||
if not user or user.passcode != passcode:
|
||||
@@ -771,13 +600,13 @@ class BaseReportAnswer(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class CidReportAnswer(BaseReportAnswer):
|
||||
cid = models.BigIntegerField(
|
||||
blank=False,
|
||||
help_text="Candidate ID",
|
||||
)
|
||||
|
||||
|
||||
class UserReportAnswer(BaseReportAnswer):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
|
||||
)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
|
||||
+3
-1
@@ -25,7 +25,9 @@ class AtlasImageColumn(tables.Column):
|
||||
for obj in value.all():
|
||||
blocks.append(obj.get_block())
|
||||
return format_html(
|
||||
"<span class='multi-image-block'>{}</span>".format("".join(blocks))
|
||||
"""<span class='multi-image-block'>
|
||||
{}
|
||||
</span>""".format("".join(blocks))
|
||||
)
|
||||
|
||||
obj = value.first()
|
||||
|
||||
+329
-70
@@ -1,4 +1,5 @@
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from typing import Tuple
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import models
|
||||
@@ -23,6 +24,22 @@ from django.contrib.auth.models import User
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from rad.settings import REMOTE_URL
|
||||
from django.utils.html import format_html
|
||||
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
|
||||
import pydicom
|
||||
import dicognito.anonymizer
|
||||
|
||||
|
||||
def findMiddle(input_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 Plane(models.Model):
|
||||
@@ -57,16 +74,19 @@ class Examination(models.Model):
|
||||
|
||||
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")
|
||||
|
||||
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):
|
||||
|
||||
authors_only = models.BooleanField(
|
||||
help_text="If true only question authors will be able to view.",
|
||||
default=False,
|
||||
@@ -85,7 +105,7 @@ class QuestionBase(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_unanswered_mark_and_text(self) -> tuple[ int, str ]:
|
||||
def get_unanswered_mark_and_text(self) -> tuple[int, str]:
|
||||
"""
|
||||
Override in models if needed
|
||||
"""
|
||||
@@ -96,6 +116,181 @@ class QuestionBase(models.Model):
|
||||
return None
|
||||
|
||||
|
||||
class SeriesBase(models.Model):
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
||||
)
|
||||
|
||||
open_access = models.BooleanField(
|
||||
help_text="If a series should be freely available to browse", default=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.pk}:{self.description}"
|
||||
|
||||
def get_author_objects(self):
|
||||
"""Returns a comma seperated text list of authors"""
|
||||
if self.author:
|
||||
return self.author.all()
|
||||
else:
|
||||
return ["None"]
|
||||
|
||||
def get_author_display(self):
|
||||
return ", ".join([i.username for i in self.get_author_objects()])
|
||||
|
||||
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_image_urls(self):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
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(self, json_output=True):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
if json_output:
|
||||
return json.dumps(images)
|
||||
else:
|
||||
# This is a mess...
|
||||
return format_html('", "'.join(images))
|
||||
def get_thumbnail(self):
|
||||
images = self.images.all()
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
|
||||
img = findMiddle(images).image
|
||||
try:
|
||||
thumbnailer = get_thumbnailer(img)
|
||||
thumbnail = thumbnailer["exam-list"]
|
||||
except InvalidImageFormatError:
|
||||
return format_html('<span title="{}">Invalid image url<span>', img), len(
|
||||
images
|
||||
)
|
||||
return format_html('<img src="/media/{}" />', thumbnail), len(images)
|
||||
|
||||
def get_thumbnail_link(self):
|
||||
return format_html(
|
||||
"<a href='{}'>{}<a/>", self.get_absolute_url(), self.get_thumbnail()[0]
|
||||
)
|
||||
|
||||
def get_block(self):
|
||||
examination = self.get_examination_full()
|
||||
thumb, image_number = self.get_thumbnail()
|
||||
return format_html(
|
||||
"""<span>
|
||||
<span class='series-block-examination'>{}</span><br/>
|
||||
<span class='series-block-thumbnail'>{}</span><br/>
|
||||
<span class='series-block-image-number'>Images: <span class='series-block-image-number-count'>{}</span></span>
|
||||
</span>""",
|
||||
examination,
|
||||
thumb,
|
||||
image_number,
|
||||
)
|
||||
|
||||
|
||||
def order_by_upload_filename(self):
|
||||
images = self.images.all()
|
||||
|
||||
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"):
|
||||
images = self.images.all()
|
||||
|
||||
files = []
|
||||
|
||||
for i in images:
|
||||
files.append((i, pydicom.dcmread(i.image.path)))
|
||||
|
||||
# print("file count: {}".format(len(files)))
|
||||
|
||||
# skip files with no SliceLocation (eg scout views)
|
||||
slices = []
|
||||
map = {}
|
||||
skipcount = 0
|
||||
for i, f in files:
|
||||
if hasattr(f, field):
|
||||
slices.append(f)
|
||||
# map[f.SliceLocation] = i
|
||||
map[f[field].value] = i
|
||||
else:
|
||||
skipcount = skipcount + 1
|
||||
|
||||
print("skipped, no {}: {}".format(field, skipcount))
|
||||
|
||||
# ensure they are in the correct order
|
||||
slices = sorted(slices, key=lambda s: s[field].value)
|
||||
|
||||
# print(slices)
|
||||
n = 1
|
||||
for f in slices:
|
||||
i = map[f[field].value]
|
||||
i.position = n
|
||||
i.save()
|
||||
n = n + 1
|
||||
|
||||
def get_total_image_size(self):
|
||||
images = self.images.all()
|
||||
|
||||
size = 0
|
||||
|
||||
for i in images:
|
||||
size += i.image.size
|
||||
|
||||
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.all():
|
||||
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 ExamBase(models.Model):
|
||||
name = models.CharField(max_length=200, help_text="Name of the exam")
|
||||
# exam_questions = SortedManyToManyField(Long, related_name="exams", blank="true")
|
||||
@@ -153,7 +348,7 @@ class ExamBase(models.Model):
|
||||
|
||||
stats_max_possible = models.FloatField(default=0)
|
||||
|
||||
#stats_graph = models.TextField(default=0)
|
||||
# stats_graph = models.TextField(default=0)
|
||||
|
||||
user_scores = models.JSONField(default=dict)
|
||||
|
||||
@@ -162,7 +357,6 @@ class ExamBase(models.Model):
|
||||
|
||||
exam_results_emailed = models.DateTimeField(default=None, null=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -191,10 +385,14 @@ class ExamBase(models.Model):
|
||||
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})
|
||||
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})
|
||||
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"
|
||||
@@ -206,7 +404,9 @@ class ExamBase(models.Model):
|
||||
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))
|
||||
return reverse(
|
||||
"{}:exam_json_cid".format(self.app_name), args=(self.pk, cid, passcode)
|
||||
)
|
||||
|
||||
def get_question_index(self, question):
|
||||
return list(self.exam_questions.all()).index(question)
|
||||
@@ -223,7 +423,14 @@ class ExamBase(models.Model):
|
||||
authors = [i for i in self.author.all()]
|
||||
return authors
|
||||
|
||||
def check_cid_user(self, cid: int|None, passcode: str|None, request: HttpRequest|None=None, user_id: int|None=None, allow_authors: bool=True):
|
||||
def check_cid_user(
|
||||
self,
|
||||
cid: int | None,
|
||||
passcode: str | None,
|
||||
request: HttpRequest | None = None,
|
||||
user_id: int | None = None,
|
||||
allow_authors: bool = True,
|
||||
):
|
||||
print(f"Check cid user: {cid=}, {passcode=}, exam_id={self.pk}")
|
||||
if request is not None and request.user.is_superuser:
|
||||
return True
|
||||
@@ -243,7 +450,6 @@ class ExamBase(models.Model):
|
||||
|
||||
# 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:
|
||||
@@ -253,7 +459,9 @@ class ExamBase(models.Model):
|
||||
|
||||
return False
|
||||
|
||||
def get_or_create_cid_user_exam(self, cid_user=None, user_user=None, start_time=None):
|
||||
def get_or_create_cid_user_exam(
|
||||
self, cid_user=None, user_user=None, start_time=None
|
||||
):
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
|
||||
if cid_user is not None:
|
||||
@@ -270,11 +478,9 @@ class ExamBase(models.Model):
|
||||
if c:
|
||||
return c
|
||||
|
||||
|
||||
if start_time is None:
|
||||
start_time = timezone.now()
|
||||
|
||||
|
||||
if cid_user is not None:
|
||||
new = CidUserExam(
|
||||
content_type=content_type,
|
||||
@@ -312,7 +518,7 @@ class ExamBase(models.Model):
|
||||
)
|
||||
|
||||
def get_cid_user_score(self, cid_user):
|
||||
c = "c/"+str(cid_user)
|
||||
c = "c/" + str(cid_user)
|
||||
if c in self.user_scores:
|
||||
return self.user_scores[c]
|
||||
else:
|
||||
@@ -326,41 +532,35 @@ class ExamBase(models.Model):
|
||||
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"]
|
||||
user_score = self.user_scores["u/" + str(user.pk)]["score"]
|
||||
|
||||
if self.app_name == "rapids":
|
||||
callstate = self.user_scores["u/"+str(user.pk)]["callstates"]
|
||||
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}%)"
|
||||
)
|
||||
percentage = f" ({user_score / int(self.stats_max_possible) * 100:.2f}%)"
|
||||
except TypeError:
|
||||
percentage = ""
|
||||
|
||||
|
||||
exam_text.append(
|
||||
f"""Results\n{'-'*len('Results')}
|
||||
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}")
|
||||
|
||||
|
||||
if self.app_name == "rapids":
|
||||
exam_text.append(
|
||||
f"Answers breakdown: {callstate}"
|
||||
)
|
||||
exam_text.append(f"Answers breakdown: {callstate}")
|
||||
|
||||
msg = "\n\n".join(exam_text)
|
||||
|
||||
return msg
|
||||
|
||||
def email_user_results(self, user, resend=False, additional_emails=None):
|
||||
#if self.results_email_sent and not resend:
|
||||
# if self.results_email_sent and not resend:
|
||||
# return False, "Already sent."
|
||||
|
||||
# Get a list of taken exams
|
||||
@@ -376,7 +576,6 @@ class ExamBase(models.Model):
|
||||
if supervisor_email:
|
||||
emails.append(supervisor_email)
|
||||
|
||||
|
||||
if additional_emails is not None:
|
||||
emails.extend(additional_emails)
|
||||
|
||||
@@ -392,14 +591,19 @@ class ExamBase(models.Model):
|
||||
except SMTPException as e:
|
||||
return [False, e]
|
||||
|
||||
#self.results_email_sent = True
|
||||
#self.save()
|
||||
# self.results_email_sent = True
|
||||
# self.save()
|
||||
return [True, ""]
|
||||
|
||||
|
||||
class ExamUserStatus(models.Model):
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
cid_user = models.ForeignKey("CidUser", blank=True, null=True, on_delete=models.SET_NULL)
|
||||
user_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL)
|
||||
cid_user = models.ForeignKey(
|
||||
"CidUser", blank=True, null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
user_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
status = models.CharField(max_length=255)
|
||||
extra = models.CharField(max_length=255)
|
||||
|
||||
@@ -413,10 +617,9 @@ class ExamUserStatus(models.Model):
|
||||
else:
|
||||
user = self.user_user.username
|
||||
return f"{self.datetime}: {user} - {self.status} ({self.extra})"
|
||||
|
||||
|
||||
|
||||
class UserAnswerBase(models.Model):
|
||||
|
||||
cid = models.BigIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -426,16 +629,13 @@ class UserAnswerBase(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ["cid"]
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{}/{}/{}".format(self.get_candidate_name(), self.exam, self.question.pk)
|
||||
|
||||
|
||||
def get_candidate_name(self) -> str:
|
||||
if self.cid is not None:
|
||||
return str(self.cid)
|
||||
@@ -454,7 +654,7 @@ class UserAnswerBase(models.Model):
|
||||
def get_answer(self):
|
||||
"""might need overriding"""
|
||||
return self.answer
|
||||
|
||||
|
||||
|
||||
class NoteType(models.Model):
|
||||
note_type = models.CharField(max_length=200)
|
||||
@@ -512,6 +712,7 @@ class QuestionNote(models.Model):
|
||||
def get_short_str(self):
|
||||
return f"{self.note_type} - {self.note}"
|
||||
|
||||
|
||||
EXAM_TYPES = (
|
||||
("physics", "physics_exams"),
|
||||
("rapids", "rapid_exams"),
|
||||
@@ -521,25 +722,31 @@ EXAM_TYPES = (
|
||||
("casecollection", "casecollection_exams"),
|
||||
)
|
||||
|
||||
class CidUser(models.Model):
|
||||
|
||||
class CidUser(models.Model):
|
||||
cid = models.IntegerField(blank=True, null=True, unique=True)
|
||||
passcode = models.CharField(blank=True, max_length=50)
|
||||
active = models.BooleanField(default=True)
|
||||
internal_candidate = models.BooleanField(default=False)
|
||||
name = models.CharField(blank=True, max_length=255)
|
||||
email = models.EmailField(blank=True)
|
||||
supervisor = models.ForeignKey("Supervisor", on_delete=models.SET_NULL, blank=True, null=True, related_name="cid_user")
|
||||
supervisor = models.ForeignKey(
|
||||
"Supervisor",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="cid_user",
|
||||
)
|
||||
|
||||
login_email_sent = models.BooleanField(default=False)
|
||||
results_email_sent = models.BooleanField(default=False)
|
||||
|
||||
group = models.ForeignKey("CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True)
|
||||
group = models.ForeignKey(
|
||||
"CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["cid"])
|
||||
]
|
||||
indexes = [models.Index(fields=["cid"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.cid)
|
||||
@@ -575,7 +782,7 @@ class CidUser(models.Model):
|
||||
|
||||
for exam in exams:
|
||||
try:
|
||||
user_score = exam.user_scores["c/"+str(self.cid)]
|
||||
user_score = exam.user_scores["c/" + str(self.cid)]
|
||||
except KeyError:
|
||||
user_score = "Score not generated"
|
||||
t = exam.get_exam_stats()
|
||||
@@ -634,7 +841,7 @@ class CidUser(models.Model):
|
||||
self.save()
|
||||
return True, ""
|
||||
|
||||
def email_details(self, resend: bool=False) -> Tuple[bool, str]:
|
||||
def email_details(self, resend: bool = False) -> Tuple[bool, str]:
|
||||
"""Email candidate details to the CID user
|
||||
|
||||
Args:
|
||||
@@ -648,7 +855,6 @@ class CidUser(models.Model):
|
||||
|
||||
login_url = reverse("cid_selector")
|
||||
|
||||
|
||||
msg = f"""
|
||||
See below for your details for: {self.group}
|
||||
|
||||
@@ -682,7 +888,7 @@ class CidUser(models.Model):
|
||||
msg,
|
||||
"no-reply@penracourses.org.uk",
|
||||
[self.email],
|
||||
#["ross.kruger@nhs.net"],
|
||||
# ["ross.kruger@nhs.net"],
|
||||
)
|
||||
email.send(fail_silently=False)
|
||||
except SMTPException as e:
|
||||
@@ -699,14 +905,16 @@ class CidUser(models.Model):
|
||||
return reverse("generic:candidate_email_results", kwargs={"cid": self.cid})
|
||||
|
||||
def get_email_results_resend_url(self):
|
||||
return reverse("generic:candidate_email_results_resend", kwargs={"cid": self.cid})
|
||||
return reverse(
|
||||
"generic:candidate_email_results_resend", kwargs={"cid": self.cid}
|
||||
)
|
||||
|
||||
|
||||
def get_next_cid():
|
||||
"""Returns the next available CID"""
|
||||
return CidUser.objects.order_by("-cid").first().cid + 1
|
||||
|
||||
|
||||
|
||||
class CidUserExam(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
@@ -715,8 +923,20 @@ class CidUserExam(models.Model):
|
||||
start_time = models.DateTimeField(blank=True, null=True)
|
||||
end_time = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE, blank=True, null=True, related_name="cid_userexam")
|
||||
user_user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="user_userexam")
|
||||
cid_user = models.ForeignKey(
|
||||
CidUser,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="cid_userexam",
|
||||
)
|
||||
user_user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="user_userexam",
|
||||
)
|
||||
|
||||
manual_submission = models.BooleanField(default=False)
|
||||
|
||||
@@ -728,7 +948,10 @@ class CidUserExam(models.Model):
|
||||
user = self.user_user.username
|
||||
else:
|
||||
user = self.cid_user.cid
|
||||
return f"{user}: {self.start_time:%Y-%m-%d %H:%M} {self.end_time:%Y-%m-%d %H:%M}"
|
||||
return (
|
||||
f"{user}: {self.start_time:%Y-%m-%d %H:%M} {self.end_time:%Y-%m-%d %H:%M}"
|
||||
)
|
||||
|
||||
|
||||
CID_GROUP_EXAMS = (
|
||||
("SBAs", "sba_cid_user_groups"),
|
||||
@@ -738,6 +961,7 @@ CID_GROUP_EXAMS = (
|
||||
("Longs", "longs_cid_user_groups"),
|
||||
)
|
||||
|
||||
|
||||
class CidUserGroup(models.Model):
|
||||
name = models.CharField(blank=True, max_length=50)
|
||||
archive = models.BooleanField(default=False)
|
||||
@@ -760,11 +984,10 @@ class CidUserGroup(models.Model):
|
||||
|
||||
return dict(exams)
|
||||
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("generic:cid_group_update", kwargs={"pk": self.pk})
|
||||
|
||||
|
||||
|
||||
USER_GROUP_EXAMS = (
|
||||
("SBAs", "sba_user_user_groups"),
|
||||
("Physics", "physics_user_user_groups"),
|
||||
@@ -773,9 +996,15 @@ USER_GROUP_EXAMS = (
|
||||
("Longs", "longs_user_user_groups"),
|
||||
)
|
||||
|
||||
|
||||
class UserUserGroup(models.Model):
|
||||
name = models.CharField(blank=True, max_length=50, help_text="Name of the User Group")
|
||||
archive = models.BooleanField(default=False, help_text="Archived groups remain on the test system but are not displayed by default")
|
||||
name = models.CharField(
|
||||
blank=True, max_length=50, help_text="Name of the User Group"
|
||||
)
|
||||
archive = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Archived groups remain on the test system but are not displayed by default",
|
||||
)
|
||||
|
||||
users = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -803,30 +1032,44 @@ class UserUserGroup(models.Model):
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("generic:user_group_update", kwargs={"pk": self.pk})
|
||||
|
||||
|
||||
|
||||
class UserGrades(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
USER_EXAM_TYPES = (
|
||||
("Physics", "user_physics_exams"),
|
||||
("Rapids", "user_rapid_exams"),
|
||||
("SBAs", "user_sba_exams"),
|
||||
("Anatomy", "user_anatomy_exams"),
|
||||
("Longs", "user_longs_exams"),
|
||||
#("CaseCollection", "user_casecollection_exams"),
|
||||
# ("CaseCollection", "user_casecollection_exams"),
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
supervisor = models.ForeignKey("Supervisor", on_delete=models.SET_NULL, blank=True, null=True, related_name="trainee")
|
||||
supervisor = models.ForeignKey(
|
||||
"Supervisor",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="trainee",
|
||||
)
|
||||
registration_number = models.CharField(max_length=25, blank=True)
|
||||
grade = models.ForeignKey(UserGrades, null=True, blank=True, help_text="User grade", on_delete=models.CASCADE)
|
||||
grade = models.ForeignKey(
|
||||
UserGrades,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User grade",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
peninsula_trainee = models.BooleanField(default=False)
|
||||
|
||||
|
||||
def getusername(self):
|
||||
return self.user.username
|
||||
|
||||
@@ -846,34 +1089,50 @@ class UserProfile(models.Model):
|
||||
|
||||
return dict(available_exams)
|
||||
|
||||
|
||||
class Supervisor(models.Model):
|
||||
"""Model to hold individual supervisor details (email and name)
|
||||
|
||||
|
||||
This can be linked with a user account.
|
||||
"""
|
||||
email = models.EmailField(unique=True, help_text="The (nhs.net) email address of the supervisor")
|
||||
|
||||
email = models.EmailField(
|
||||
unique=True, help_text="The (nhs.net) email address of the supervisor"
|
||||
)
|
||||
name = models.CharField(max_length=255, help_text="Name of the supervisor")
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, help_text="If the supervisor has an account on the test system that can be associated here")
|
||||
site = models.ForeignKey("Site", on_delete=models.SET_NULL, null=True, blank=True, help_text="Hospital site at which the supervisor is based")
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="If the supervisor has an account on the test system that can be associated here",
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
"Site",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Hospital site at which the supervisor is based",
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.email})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('generic:supervisor_detail', kwargs={'pk': self.pk})
|
||||
return reverse("generic:supervisor_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def clean(self):
|
||||
self.email = self.email.strip()
|
||||
self.name = self.name.strip()
|
||||
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.userprofile.save()
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2023-06-12 09:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('longs', '0069_alter_long_feedback'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='longseries',
|
||||
name='open_access',
|
||||
field=models.BooleanField(default=True, help_text='If a series should be freely available to browse'),
|
||||
),
|
||||
]
|
||||
+3
-180
@@ -32,13 +32,12 @@ from generic.models import (
|
||||
CidUserGroup,
|
||||
ExamUserStatus,
|
||||
Examination,
|
||||
# Condition,
|
||||
# Sign,
|
||||
ExamBase,
|
||||
Plane,
|
||||
Contrast,
|
||||
QuestionBase,
|
||||
QuestionNote,
|
||||
SeriesBase,
|
||||
UserAnswerBase,
|
||||
UserUserGroup,
|
||||
)
|
||||
@@ -70,14 +69,6 @@ def image_directory_path(instance, filename):
|
||||
return "longs/picture/{0}".format(filename)
|
||||
|
||||
|
||||
def findMiddle(input_list):
|
||||
middle = float(len(input_list)) / 2
|
||||
if middle % 2 != 0:
|
||||
return input_list[int(middle - 0.5)]
|
||||
else:
|
||||
return input_list[int(middle)]
|
||||
return (input_list[int(middle)], input_list[int(middle - 1)])
|
||||
|
||||
|
||||
@reversion.register
|
||||
class Long(QuestionBase):
|
||||
@@ -380,7 +371,7 @@ class LongSeriesImage(models.Model):
|
||||
|
||||
|
||||
@reversion.register
|
||||
class LongSeries(models.Model):
|
||||
class LongSeries(SeriesBase):
|
||||
modality = models.ForeignKey(
|
||||
Modality, related_name="series_modality", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
@@ -407,16 +398,6 @@ class LongSeries(models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
# long = models.ManyToManyField(
|
||||
# "Long",
|
||||
# help_text="The question(s) this series should be associated with",
|
||||
# related_name="series",
|
||||
# blank=True,
|
||||
# )
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
||||
)
|
||||
|
||||
author = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -424,169 +405,11 @@ class LongSeries(models.Model):
|
||||
related_name="long_series",
|
||||
)
|
||||
|
||||
open_access = models.BooleanField(
|
||||
help_text="If a question should be freely available to browse", default=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
# if self.long:
|
||||
# long_id = ", ".format([long.pk for long in self.long.all()])
|
||||
# # long_id = self.long.pk
|
||||
# else:
|
||||
# long_id = "None"
|
||||
return "{}/{} : {}".format(self.pk, self.get_examination(), self.description)
|
||||
|
||||
def get_author_objects(self):
|
||||
"""Returns a comma seperated text list of authors"""
|
||||
if self.author:
|
||||
return self.author.all()
|
||||
else:
|
||||
return ["None"]
|
||||
if self.long:
|
||||
authors = [i for i in self.long.author.all()]
|
||||
else:
|
||||
authors = []
|
||||
|
||||
return authors
|
||||
|
||||
def get_author_display(self):
|
||||
return ", ".join([i.username for i in self.get_author_objects()])
|
||||
|
||||
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_absolute_url(self):
|
||||
return reverse("longs:long_series_detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_image_urls(self, feedback=False):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
return ",".join(images)
|
||||
|
||||
def get_image_url_array(self, feedback=False):
|
||||
images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()]
|
||||
|
||||
return json.dumps(images)
|
||||
|
||||
def get_thumbnail(self):
|
||||
images = self.images.all()
|
||||
|
||||
if len(images) < 1:
|
||||
return "No images", 0
|
||||
|
||||
img = findMiddle(images).image
|
||||
try:
|
||||
thumbnailer = get_thumbnailer(img)
|
||||
thumbnail = thumbnailer["exam-list"]
|
||||
except InvalidImageFormatError:
|
||||
return format_html('<span title="{}">Invalid image url<span>', img), len(
|
||||
images
|
||||
)
|
||||
return format_html('<img src="/media/{}" />', thumbnail), len(images)
|
||||
|
||||
def get_block(self):
|
||||
examination = self.get_examination_full()
|
||||
thumb, image_number = self.get_thumbnail()
|
||||
return format_html(
|
||||
"<div>{}<br/>{}<br/>Images: {}</div>", examination, thumb, image_number
|
||||
)
|
||||
|
||||
def order_by_upload_filename(self):
|
||||
images = self.images.all()
|
||||
|
||||
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"):
|
||||
images = self.images.all()
|
||||
|
||||
files = []
|
||||
|
||||
for i in images:
|
||||
files.append((i, pydicom.dcmread(i.image.path)))
|
||||
|
||||
# print("file count: {}".format(len(files)))
|
||||
|
||||
# skip files with no SliceLocation (eg scout views)
|
||||
slices = []
|
||||
map = {}
|
||||
skipcount = 0
|
||||
for i, f in files:
|
||||
if hasattr(f, field):
|
||||
slices.append(f)
|
||||
# map[f.SliceLocation] = i
|
||||
map[f[field].value] = i
|
||||
else:
|
||||
skipcount = skipcount + 1
|
||||
|
||||
print("skipped, no {}: {}".format(field, skipcount))
|
||||
|
||||
# ensure they are in the correct order
|
||||
slices = sorted(slices, key=lambda s: s[field].value)
|
||||
|
||||
# print(slices)
|
||||
n = 1
|
||||
for f in slices:
|
||||
i = map[f[field].value]
|
||||
i.position = n
|
||||
i.save()
|
||||
n = n + 1
|
||||
|
||||
def get_total_image_size(self):
|
||||
images = self.images.all()
|
||||
|
||||
size = 0
|
||||
|
||||
for i in images:
|
||||
size += i.image.size
|
||||
|
||||
return size
|
||||
|
||||
|
||||
# class LongImage(models.Model):
|
||||
# long = models.ForeignKey(Long,
|
||||
# related_name="images",
|
||||
# on_delete=models.CASCADE)
|
||||
# image = models.ImageField(upload_to=image_directory_path, storage=image_storage)
|
||||
#
|
||||
# feedback_image = models.BooleanField(default=False)
|
||||
#
|
||||
# def image_tag(self):
|
||||
# if self.image:
|
||||
# return mark_safe(
|
||||
# '<img src="{}" class="admin-long-image" /><span class="admin-long-image-info">Click and hold to zoom<span>'
|
||||
# .format(self.image.url))
|
||||
# else:
|
||||
# return ""
|
||||
#
|
||||
# image_tag.short_description = 'Image'
|
||||
return reverse("longs:series_detail", kwargs={"pk": self.pk})
|
||||
|
||||
|
||||
class LongCreationDefault(models.Model):
|
||||
|
||||
Reference in New Issue
Block a user