Compare commits

...
3 Commits
Author SHA1 Message Date
Ross 5a9868a56d image findings will now save and reload stack index 2024-09-23 14:05:09 +01:00
Ross edc9b29233 add basic (user) history for collections 2024-09-23 11:49:36 +01:00
Ross 5f23134fa6 fix email / first name missmatch 2024-09-23 10:02:01 +01:00
14 changed files with 393 additions and 72 deletions
+65 -31
View File
@@ -29,7 +29,6 @@ from loguru import logger
router = Router() router = Router()
class SeriesSchema(ModelSchema): class SeriesSchema(ModelSchema):
case_id: List[int] = [] case_id: List[int] = []
@@ -59,6 +58,7 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
uploaded = [] uploaded = []
duplicate = [] duplicate = []
failed = [] failed = []
duplicate_series = set()
for file in files: for file in files:
# data = file.read() # data = file.read()
try: try:
@@ -67,26 +67,41 @@ def upload_dicom(request, files: List[UploadedFile] = File(...)):
ud.save() ud.save()
uploaded.append((file.name, ud.image_blake3_hash)) uploaded.append((file.name, ud.image_blake3_hash))
except DuplicateDicom: except DuplicateDicom as e:
duplicate.append((file.name, ud.image_blake3_hash)) duplicate.append((file.name, ud.image_blake3_hash))
duplicate_series.add(e.duplicate.get_series().get_absolute_url())
print(duplicate_series)
pass pass
except InvalidDicomError: except InvalidDicomError:
failed.append(file.name) failed.append(file.name)
return {"uploaded": uploaded, "duplicates": duplicate, "failed": failed} return {
"uploaded": uploaded,
"duplicates": duplicate,
"failed": failed,
"duplicate_series": list(duplicate_series),
}
@router.post("/generate_image_hash", auth=django_auth) @router.post("/generate_image_hash", auth=django_auth)
def generate_image_hash(request, id:int): def generate_image_hash(request, id: int):
s = SeriesImage.objects.get(pk=id) s = SeriesImage.objects.get(pk=id)
s.generate_hashes() s.generate_hashes()
return {"md5": s.image_md5_hash, "blake3": s.image_blake3_hash, "is_dicom": s.is_dicom} return {
"md5": s.image_md5_hash,
"blake3": s.image_blake3_hash,
"is_dicom": s.is_dicom,
}
@router.post("/clear_dicoms", auth=django_auth) @router.post("/clear_dicoms", auth=django_auth)
def clear_dicoms(request): def clear_dicoms(request):
if "selection" in request.POST: if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(series_instance_uid__in=request.POST.getlist("selection")) dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else: else:
dicoms = UncategorisedDicom.objects.filter(user=request.user) dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -110,15 +125,17 @@ def uncategorised_dicoms(request):
return data return data
def import_dicoms_helper(request, case_id: int | None = None): def import_dicoms_helper(request, case_id: int | None = None):
#dicoms = UncategorisedDicom.objects.filter(user=request.user) # dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "selection" in request.POST: if "selection" in request.POST:
dicoms = UncategorisedDicom.objects.filter(series_instance_uid__in=request.POST.getlist("selection")) dicoms = UncategorisedDicom.objects.filter(
series_instance_uid__in=request.POST.getlist("selection")
)
else: else:
dicoms = UncategorisedDicom.objects.filter(user=request.user) dicoms = UncategorisedDicom.objects.filter(user=request.user)
if "order-series" in request.POST: if "order-series" in request.POST:
order = request.POST["order-series"] order = request.POST["order-series"]
else: else:
@@ -136,7 +153,9 @@ def import_dicoms_helper(request, case_id: int | None = None):
tags = data[series_uid][0][1] tags = data[series_uid][0][1]
# Check if series with the id already exists (in which case we just add to htat) # Check if series with the id already exists (in which case we just add to htat)
if Series.objects.filter(series_instance_uid=tags["SeriesInstanceUID"]).exists(): if Series.objects.filter(
series_instance_uid=tags["SeriesInstanceUID"]
).exists():
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"]) series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
else: else:
series = Series( series = Series(
@@ -146,16 +165,16 @@ def import_dicoms_helper(request, case_id: int | None = None):
) )
if tags["StudyDescription"]: if tags["StudyDescription"]:
examination, created = Examination.objects.get_or_create(examination=tags["StudyDescription"]) examination, created = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
)
if created: if created:
examination.modality = modality examination.modality = modality
examination.save() examination.save()
series.examination = examination series.examination = examination
series.save() series.save()
match order: match order:
case "order-series-instance-number": case "order-series-instance-number":
series.order_by_dicom("InstanceNumber") series.order_by_dicom("InstanceNumber")
@@ -188,12 +207,18 @@ def import_dicoms_helper(request, case_id: int | None = None):
return series_list return series_list
@router.post("/import_dicoms", auth=django_auth, response=List[Tuple[ SeriesSchema, str ]]) @router.post(
"/import_dicoms", auth=django_auth, response=List[Tuple[SeriesSchema, str]]
)
def import_dicoms(request): def import_dicoms(request):
return import_dicoms_helper(request) return import_dicoms_helper(request)
@router.post("/import_dicoms/{case_id}", auth=django_auth, response=List[Tuple[ SeriesSchema, str ]]) @router.post(
"/import_dicoms/{case_id}",
auth=django_auth,
response=List[Tuple[SeriesSchema, str]],
)
def import_dicoms_case(request, case_id: int): def import_dicoms_case(request, case_id: int):
return import_dicoms_helper(request, case_id=case_id) return import_dicoms_helper(request, case_id=case_id)
@@ -220,8 +245,9 @@ def series_remove_duplicate_images(request, series_id: int):
return len(dupes) return len(dupes)
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth) @router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end:int): def series_truncate(request, series_id: int, start: int, end: int):
print(start, end) print(start, end)
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
@@ -230,11 +256,11 @@ def series_truncate(request, series_id: int, start: int, end:int):
images_removed = [] images_removed = []
for n, image in enumerate(series.get_images()): for n, image in enumerate(series.get_images()):
if n >= start and n <=end: if n >= start and n <= end:
continue continue
else: else:
print(n, image) print(n, image)
image.removed=True image.removed = True
image.image = None image.image = None
image.save() image.save()
images_removed.append(image.pk) images_removed.append(image.pk)
@@ -261,6 +287,7 @@ def check_image_hash(request, hash: str):
data = {"status": "success", "id": False} data = {"status": "success", "id": False}
return data return data
@router.post("/check_image_hashes/", auth=django_auth) @router.post("/check_image_hashes/", auth=django_auth)
def check_images_hashes(request, hashes: List[str]): def check_images_hashes(request, hashes: List[str]):
"""Checks a list of image hashes and returns the series id / url if found """Checks a list of image hashes and returns the series id / url if found
@@ -278,21 +305,28 @@ def check_images_hashes(request, hashes: List[str]):
data = { data = {
"id": series_image.pk, "id": series_image.pk,
"url": series_image.series.get_absolute_url(), "url": series_image.series.get_absolute_url(),
"type": "series" "type": "series",
} }
except SeriesImage.DoesNotExist: except SeriesImage.DoesNotExist:
try: try:
uncategorised_dicom = UncategorisedDicom.objects.get(image_blake3_hash=hash) uncategorised_dicom = UncategorisedDicom.objects.get(
data = { "id":uncategorised_dicom.pk, "url": reverse("atlas:user_uploads"), "type": "uncategorised"} image_blake3_hash=hash
)
data = {
"id": uncategorised_dicom.pk,
"url": reverse("atlas:user_uploads"),
"type": "uncategorised",
}
except UncategorisedDicom.DoesNotExist: except UncategorisedDicom.DoesNotExist:
data = { "id": False, "url": False} data = {"id": False, "url": False}
hash_status[hash] = data hash_status[hash] = data
return hash_status return hash_status
#@router.get("/generate_image_hash/{id}", auth=django_auth)
#def generate_image_hash(request, id: int): # @router.get("/generate_image_hash/{id}", auth=django_auth)
# def generate_image_hash(request, id: int):
# series_image = SeriesImage.objects.get(pk=id) # series_image = SeriesImage.objects.get(pk=id)
# #
# series_image.image_blake3_hash = "" # series_image.image_blake3_hash = ""
@@ -301,21 +335,22 @@ def check_images_hashes(request, hashes: List[str]):
# #
# print(series_image) # print(series_image)
@router.get("/view_dicom_tags/{hash}", auth=django_auth) @router.get("/view_dicom_tags/{hash}", auth=django_auth)
def view_dicom_tags(request, hash: str): def view_dicom_tags(request, hash: str):
item = SeriesImage.objects.get(image_blake3_hash=hash) item = SeriesImage.objects.get(image_blake3_hash=hash)
return item.get_dicom_json() return item.get_dicom_json()
@router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth) @router.get("/series_split_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
def series_split_by_tag(request, series_id: int, dicom_tag:str): def series_split_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user): if not series.check_user_can_edit(request.user):
return {"status": "permission denied"} return {"status": "permission denied"}
if dicom_tag.startswith("(") and dicom_tag.endswith(")"): if dicom_tag.startswith("(") and dicom_tag.endswith(")"):
dicom_tag = tuple([hex(int(i.strip(),16)) for i in dicom_tag[1:-1].split(",")]) dicom_tag = tuple([hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")])
image_map = defaultdict(list) image_map = defaultdict(list)
@@ -324,7 +359,7 @@ def series_split_by_tag(request, series_id: int, dicom_tag:str):
print(image) print(image)
ds = image.get_dicom_data() ds = image.get_dicom_data()
#print("2a", ds[dicom_tag]) # print("2a", ds[dicom_tag])
if dicom_tag in ds: if dicom_tag in ds:
val = ds[dicom_tag].value val = ds[dicom_tag].value
@@ -353,12 +388,11 @@ def series_split_by_tag(request, series_id: int, dicom_tag:str):
new_series.append(series.pk) new_series.append(series.pk)
return new_series return new_series
@router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth) @router.get("/split_order_by_dicom_tag/{series_id}/{dicom_tag}", auth=django_auth)
def series_order_by_tag(request, series_id: int, dicom_tag:str): def series_order_by_tag(request, series_id: int, dicom_tag: str):
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
series.order_by_dicom(dicom_tag) series.order_by_dicom(dicom_tag)
+1 -1
View File
@@ -143,7 +143,7 @@ class StructureForm(ModelForm):
class SeriesFindingForm(ModelForm): class SeriesFindingForm(ModelForm):
class Meta: class Meta:
model = SeriesFinding model = SeriesFinding
exclude = ["series", "annotation_json", "viewport_json"] exclude = ["series", "annotation_json", "viewport_json", "current_image_id_index"]
widgets = { widgets = {
"findings": autocomplete.ModelSelect2Multiple( "findings": autocomplete.ModelSelect2Multiple(
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-09-23 12:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0059_alter_casecollection_markers'),
]
operations = [
migrations.AddField(
model_name='seriesfinding',
name='current_image_id_index',
field=models.IntegerField(blank=True, null=True),
),
]
+30 -3
View File
@@ -555,6 +555,14 @@ class SeriesImage(SeriesImageBase):
help_text="Reference to the object that has replaced this one.", help_text="Reference to the object that has replaced this one.",
) )
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): def get_dicom_data(self):
try: try:
with pydicom.dcmread(self.image) as d: with pydicom.dcmread(self.image) as d:
@@ -562,6 +570,9 @@ class SeriesImage(SeriesImageBase):
except InvalidDicomError: except InvalidDicomError:
return {} return {}
def get_series(self):
return self.series
# def get_image_dicom_json(self, image_index): # def get_image_dicom_json(self, image_index):
# try: # try:
# with pydicom.dcmread(self.image) as ds: # with pydicom.dcmread(self.image) as ds:
@@ -586,6 +597,7 @@ class SeriesFinding(models.Model):
conditions = models.ManyToManyField(Condition, blank=True) conditions = models.ManyToManyField(Condition, blank=True)
annotation_json = models.TextField(null=True, blank=True) annotation_json = models.TextField(null=True, blank=True)
viewport_json = models.TextField(null=True, blank=True) viewport_json = models.TextField(null=True, blank=True)
current_image_id_index = models.IntegerField(null=True, blank=True)
def __str__(self) -> str: def __str__(self) -> str:
findings = self.findings.all().values_list("name") findings = self.findings.all().values_list("name")
@@ -1066,6 +1078,13 @@ class CaseDetail(models.Model):
"""Returns the correct question answers as a json string""" """Returns the correct question answers as a json string"""
return json.dumps(self.question_answers) return json.dumps(self.question_answers)
def get_user_answers(self, user):
"""Returns the users answers as a json string"""
try:
return UserReportAnswer.objects.get(question=self, user=user)
except UserReportAnswer.DoesNotExist:
return None
class CasePrior(models.Model): class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case") prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
@@ -1258,9 +1277,12 @@ class UncategorisedDicom(models.Model):
basic_dicom_tags = models.JSONField(null=True, blank=True) basic_dicom_tags = models.JSONField(null=True, blank=True)
def check_for_duplicates(self, image_hash): def check_for_duplicates(self, image_hash=None|str):
duplicate = None duplicate = None
if image_hash is None:
image_hash = self.image_blake3_hash
if obj := UncategorisedDicom.objects.filter( if obj := UncategorisedDicom.objects.filter(
image_blake3_hash=image_hash image_blake3_hash=image_hash
).first(): ).first():
@@ -1295,7 +1317,8 @@ class UncategorisedDicom(models.Model):
if duplicate is not None: if duplicate is not None:
if duplicate != self: if duplicate != self:
raise DuplicateDicom raise DuplicateDicom(duplicate)
# Hack for tests # Hack for tests
if image_blake3_hash != "1234": if image_blake3_hash != "1234":
@@ -1334,9 +1357,13 @@ class UncategorisedDicom(models.Model):
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
return None return None
def get_series(self):
return self.series
class DuplicateDicom(Exception): class DuplicateDicom(Exception):
pass def __init__(self, duplicate):
self.duplicate = duplicate
class Resource(models.Model, AuthorMixin): class Resource(models.Model, AuthorMixin):
@@ -1,5 +1,6 @@
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<br/>Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> / <br/>Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=collection.pk %}">Overview</a> /
<a href="{% url 'atlas:collection_history' collection.pk %}">History</a> /
<a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> / <a href="{% url 'atlas:collection_mark_overview' collection.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> / <a href="{% url 'atlas:collection_scores_cid' collection.pk %}">Scores</a> /
<a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> / <a href="{% url 'atlas:exam_cids' collection.pk %}">Candidates</a> /
@@ -0,0 +1,20 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>{{collection.name}}</h2>
<h3>Users</h3>
<ul>
{% for userexam in userexams %}
<li>
<b><a href="{% url 'atlas:collection_history_user' collection.pk userexam.user_user.pk %}">{{userexam.get_user_name}}</a><b><br/>
Completed: {{userexam.completed}}<br/>
Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}<br/>
</li>
{% endfor %}
</ul>
{% endblock %}
@@ -0,0 +1,87 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Collection: {{collection.name}}</h2>
<h3>User: {{user}}</h3>
<ul>
{% for casedetail, user_answer in user_answers %}
<li class="case">
<h4>{{forloop.counter}} / Case: {{casedetail.case.title}}</h4>
{% if not user_answer %}
<span class="case-not-answered">Case not answered.</span>
{% else %}
<div class="answer-block">
{% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
{% if not user_answer %}
Not answered
{% else %}
<div class="{% if answer_is_correct %}
correct
{% else %}
incorrect
{% endif %}
{% if automark %}
automark
{% endif %}
">
<h5>{{value.title}}</h5>
{{value.description}}
<div
>
Answer : {{user_answer}}
{% if not answer_is_correct %}
<br/>Correct answer: {{correct_answer}}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
{% block css %}
{{ block.super }}
<style>
.correct {
}
.correct h5::after {
content: '✓';
color: green;
}
.incorrect h5::after {
content: '✗';
color: orange;
}
.incorrect.automark h5::after {
content: '✗';
color: red;
}
.answer-block>div {
padding-bottom: 10px;
}
.case:has(.case-not-answered) {
opacity: 0.5;
}
</style>
{% endblock %}
+11
View File
@@ -41,6 +41,7 @@
<ul id="uploaded-files"></ul> <ul id="uploaded-files"></ul>
<h5>Duplicates</h5> <h5>Duplicates</h5>
<ul id="duplicate-files"></ul> <ul id="duplicate-files"></ul>
<ul id="duplicate-series"></ul>
<h5>Failed</h5> <h5>Failed</h5>
<ul id="failed-files"></ul> <ul id="failed-files"></ul>
</div> </div>
@@ -78,6 +79,7 @@
<script> <script>
window.to_upload = []; window.to_upload = [];
window.upload_count = 1; window.upload_count = 1;
window.duplicate_series = new Set()
function* chunks(arr, n) { function* chunks(arr, n) {
for (let i = 0; i < arr.length; i += n) { for (let i = 0; i < arr.length; i += n) {
@@ -149,6 +151,7 @@
item.textContent = JSON.parse(xhr.response)["duplicates"][i]; item.textContent = JSON.parse(xhr.response)["duplicates"][i];
$("#duplicate-files").append(item); $("#duplicate-files").append(item);
} }
window.duplicate_series.add(...JSON.parse(xhr.response)["duplicate_series"]);
for (let i = 0; i < JSON.parse(xhr.response)["failed"].length; i++) { for (let i = 0; i < JSON.parse(xhr.response)["failed"].length; i++) {
let item = document.createElement("li"); let item = document.createElement("li");
@@ -162,6 +165,14 @@
} }
if (current_chunk + 1 == total_chunks) { if (current_chunk + 1 == total_chunks) {
$("#loading").hide(); $("#loading").hide();
window.duplicate_series.forEach((element) => {
let item = document.createElement("a");
item.href = element;
item.textContent = element;
$("#duplicate-series").append(item, "<br/>");
});
//$("#duplicate-series").append([...window.duplicate_series].join("<br/>"));
alert("Uploading complete") alert("Uploading complete")
} }
} }
+35 -13
View File
@@ -1,3 +1,5 @@
{% load crispy_forms_tags %}
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div> <div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
<div>Description: {{series.description}}</div> <div>Description: {{series.description}}</div>
@@ -22,13 +24,7 @@
<div class="hide" id="hidden-form"> <div class="hide" id="hidden-form">
<form method="post" id="series_finding_form"> <form method="post" id="series_finding_form">
{% csrf_token %} {% csrf_token %}
{{series_finding_form.management_form}} {{series_finding_form|crispy}}
<div>
<div>Description: {{ series_finding_form.description }}</div>
<div>Findings: {{ series_finding_form.findings }}</div>
<div>Structures: {{ series_finding_form.structures }}</div>
<div>Conditions: {{ series_finding_form.conditions }}</div>
</div>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
<button id="cancel-add-finding-button">Cancel</button> <button id="cancel-add-finding-button">Cancel</button>
</form> </form>
@@ -45,14 +41,28 @@
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box"> <div class="finding-box">
<button class="view-finding-button" data-annotationjson={{finding.annotation_json}} <button class="view-finding-button" data-annotationjson={{finding.annotation_json}}
data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}}>Click to view</button> data-viewportjson={{finding.viewport_json}} data-findingid={{finding.id}} data-currentimageid={{finding.current_image_id_index}}>Click to view</button>
<span class="view-finding-details"> <span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br /> Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br /> Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br /> Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
Description: {{finding.description}}<br /> Description: {{finding.description}}<br />
{% if request.user.is_superuser %}
<span _="on click toggle .hidden on next .extra-details">+</span>
<div class="hidden extra-details">
<h4>Annotation JSON</h4>
<pre>{{finding.annotation_json}}</pre>
<h4>Viewport JSON</h4>
<pre>{{finding.viewport_json}}</pre>
<h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre>
</div>
{% endif %}
</span> </span>
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a> <a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a> <a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
</div> </div>
@@ -102,7 +112,7 @@
{% if image.image %} {% if image.image %}
[{{image.image.size|filesizeformat}}] [{{image.get_file_size|filesizeformat}}]
{% endif %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}} {{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
@@ -195,6 +205,7 @@
}) })
$("#add-finding-button").click(() => { $("#add-finding-button").click(() => {
$("#hidden-form").show()
dicom_element = $(".cornerstone-element").get(0); dicom_element = $(".cornerstone-element").get(0);
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element); cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.reset(dicom_element); cornerstone.reset(dicom_element);
@@ -202,7 +213,6 @@
//$("#finding-form").empty().append( //$("#finding-form").empty().append(
// $("#hidden-form form").clone() // $("#hidden-form form").clone()
//); //);
$("#hidden-form").show()
}); });
$("#cancel-add-finding-button").click((e) => { $("#cancel-add-finding-button").click((e) => {
@@ -225,7 +235,9 @@
dicom_element = $(".cornerstone-element").get(0); dicom_element = $(".cornerstone-element").get(0);
annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson); annotationjson = JSON.parse(e.currentTarget.dataset.annotationjson);
viewport = JSON.parse(e.currentTarget.dataset.viewportjson); viewport = JSON.parse(e.currentTarget.dataset.viewportjson);
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element); current_image_id_index = e.currentTarget.dataset.currentimageid;
console.log(e.currentTarget, current_image_id_index)
loadAnnotationAndViewportOnElement(annotationjson, viewport, dicom_element, current_image_id_index);
}); });
@@ -249,17 +261,26 @@
{% endif %} {% endif %}
}); });
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element) { function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element); cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.getEnabledElement(dicom_element).viewport = viewport cornerstone.getEnabledElement(dicom_element).viewport = viewport
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState( cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(
annotation); annotation);
console.log("annotation", annotation) console.log("annotation", annotation)
console.log("viewport", viewport) console.log("viewport", viewport)
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson) //cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
cornerstone.resize(dicom_element); cornerstone.resize(dicom_element);
current_image_id_index = parseInt(current_image_id_index);
dicomViewer.getNextAnnotationImage(dicom_element); console.log(current_image_id_index, Number.isInteger(current_image_id_index))
if (Number.isInteger(current_image_id_index)) {
console.log("loading stack index")
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
} else {
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
}
} }
@@ -276,6 +297,7 @@
description: $('#id_description').val(), description: $('#id_description').val(),
series: {{ series.pk }}, series: {{ series.pk }},
//annotation_json: JSON.stringify(c.toolStateManager.saveToolState()), //annotation_json: JSON.stringify(c.toolStateManager.saveToolState()),
current_image_id_index: c.toolStateManager.toolState.stack.data[0].currentImageIdIndex,
annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager
.saveToolState()), .saveToolState()),
viewport_json: JSON.stringify(c.viewport), viewport_json: JSON.stringify(c.viewport),
+11
View File
@@ -85,6 +85,17 @@ urlpatterns = [
views.GenericExamViews.exam_cids, views.GenericExamViews.exam_cids,
name="exam_cids", name="exam_cids",
), ),
path(
"collection/<int:exam_id>/history",
views.collection_history,
name="collection_history",
),
path(
"collection/<int:exam_id>/history/<int:user_pk>/user",
views.collection_history_user,
name="collection_history_user",
),
path( path(
"collection/<int:pk>/user_status", "collection/<int:pk>/user_status",
views.GenericExamViews.exam_user_status, views.GenericExamViews.exam_user_status,
+81 -16
View File
@@ -246,6 +246,7 @@ def question_schema_detail(request, pk: int):
{"question_schema": question_schema, "example_form": example_form}, {"question_schema": question_schema, "example_form": example_form},
) )
@login_required @login_required
# @user_is_atlas_editor # @user_is_atlas_editor
def question_schema_schemas(request): def question_schema_schemas(request):
@@ -259,6 +260,7 @@ def question_schema_schemas(request):
}, },
) )
@login_required @login_required
# @user_is_atlas_editor # @user_is_atlas_editor
def condition_detail(request, pk): def condition_detail(request, pk):
@@ -857,14 +859,17 @@ class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
model = QuestionSchema model = QuestionSchema
form_class = QuestionSchemaForm form_class = QuestionSchemaForm
class QuestionSchemaUpdate(LoginRequiredMixin, UpdateView): class QuestionSchemaUpdate(LoginRequiredMixin, UpdateView):
model = QuestionSchema model = QuestionSchema
form_class = QuestionSchemaForm form_class = QuestionSchemaForm
class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView): class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = QuestionSchema model = QuestionSchema
template_name = "confirm_delete.html" template_name = "confirm_delete.html"
class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView): class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
model = Finding model = Finding
form_class = FindingForm form_class = FindingForm
@@ -1272,7 +1277,6 @@ def series_order_dicom(request, pk):
@login_required @login_required
def image_diff(request): def image_diff(request):
if request.htmx: if request.htmx:
image_ids = request.POST.getlist("image-id") image_ids = request.POST.getlist("image-id")
if len(image_ids) == 2: if len(image_ids) == 2:
@@ -1381,6 +1385,7 @@ def create_series_findings(request):
description = request.POST.get("description") description = request.POST.get("description")
annotation_json = request.POST.get("annotation_json") annotation_json = request.POST.get("annotation_json")
viewport_json = request.POST.get("viewport_json") viewport_json = request.POST.get("viewport_json")
current_image_id_index = int(request.POST.get("current_image_id_index"))
series = Series.objects.get(pk=series_id) series = Series.objects.get(pk=series_id)
findings = Finding.objects.filter(pk__in=findings_ids) findings = Finding.objects.filter(pk__in=findings_ids)
@@ -1404,6 +1409,7 @@ def create_series_findings(request):
sf.findings.set(findings) sf.findings.set(findings)
sf.structures.set(structures) sf.structures.set(structures)
sf.conditions.set(conditions) sf.conditions.set(conditions)
sf.current_image_id_index = current_image_id_index
sf.save() sf.save()
return JsonResponse({"success": True}) return JsonResponse({"success": True})
@@ -1596,6 +1602,58 @@ def collection_detail(request, pk):
) )
@user_is_collection_author_or_atlas_editor
def collection_history(request, exam_id: int):
"""An overview page that shows who has attempted / finised the collection"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
casedetails = CaseDetail.objects.filter(collection=collection).order_by(
"sort_order"
)
userexams = collection.get_cid_user_exams()
print(userexams)
return render(
request,
"atlas/collection_history.html",
{
"collection": collection,
"casesdetails": casedetails,
"can_edit": True,
"userexams": userexams,
},
)
@user_is_collection_author_or_atlas_editor
def collection_history_user(request, exam_id: int, user_pk: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
user = get_object_or_404(User, pk=user_pk)
casedetails = CaseDetail.objects.filter(
collection=collection,
).order_by("sort_order")
print(user)
print(casedetails)
user_answers = [(casedetail, casedetail.get_user_answers(user)) for casedetail in casedetails]
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": user,
"casedetails": casedetails,
"user_answers": user_answers,
},
)
def collection_take_start(request, pk): def collection_take_start(request, pk):
"""The starting page for taking / reviewing a case collection """The starting page for taking / reviewing a case collection
@@ -1630,23 +1688,29 @@ def collection_take_start(request, pk):
request, "atlas/collection_review_start.html", template_variables request, "atlas/collection_review_start.html", template_variables
) )
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_priors(request, exam_id, case_id): def collection_case_priors(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
if request.htmx: if request.htmx:
if "remove" in request.POST: if "remove" in request.POST:
p = CasePrior.objects.get(case_detail=case_detail, prior_case=request.POST["remove"]) p = CasePrior.objects.get(
case_detail=case_detail, prior_case=request.POST["remove"]
)
p.delete() p.delete()
return HttpResponse(f"Case removed") return HttpResponse(f"Case removed")
elif "prior_case_id" in request.POST: elif "prior_case_id" in request.POST:
prior_case = Case.objects.get(pk=request.POST["prior_case_id"]) prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
p, created = CasePrior.objects.get_or_create(case_detail=case_detail, prior_case=prior_case) p, created = CasePrior.objects.get_or_create(
p.relation_text=request.POST["relation"] case_detail=case_detail, prior_case=prior_case
)
p.relation_text = request.POST["relation"]
if not p.relation_text: if not p.relation_text:
return HttpResponse("You need to enter text to describe the relationship between the cases") return HttpResponse(
"You need to enter text to describe the relationship between the cases"
)
p.save() p.save()
@@ -1674,7 +1738,6 @@ def collection_case_priors(request, exam_id, case_id):
else: else:
available_priors.append((case, False, "")) available_priors.append((case, False, ""))
form = PriorCaseForm() form = PriorCaseForm()
case_number, case_count = collection.get_index_of_case( case_number, case_count = collection.get_index_of_case(
@@ -1684,14 +1747,13 @@ def collection_case_priors(request, exam_id, case_id):
previous = collection.get_previous_case(case_detail.case) previous = collection.get_previous_case(case_detail.case)
next = collection.get_next_case(case_detail.case) next = collection.get_next_case(case_detail.case)
return render( return render(
request, request,
"atlas/collection_case_priors.html", "atlas/collection_case_priors.html",
{ {
"case_detail": case_detail, "case_detail": case_detail,
"form": form, "form": form,
#"example_form": example_form, # "example_form": example_form,
"collection": collection, "collection": collection,
"case": case_detail.case, "case": case_detail.case,
"previous": previous, "previous": previous,
@@ -1703,6 +1765,7 @@ def collection_case_priors(request, exam_id, case_id):
}, },
) )
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_details(request, exam_id, case_id): def collection_case_details(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
@@ -1713,7 +1776,6 @@ def collection_case_details(request, exam_id, case_id):
raise Http404("Collection not in question mode") raise Http404("Collection not in question mode")
if request.method == "POST": if request.method == "POST":
# Called if the user saves the correct answer form # Called if the user saves the correct answer form
if request.POST.get("submit") == "answer": if request.POST.get("submit") == "answer":
# answers = request.POST # answers = request.POST
@@ -1986,7 +2048,9 @@ def collection_take_overview(
cid_user_exam.save() cid_user_exam.save()
collection.exam_user_status.create( collection.exam_user_status.create(
cid_user_exam=cid_user_exam, status="submitted", extra="manual submission" cid_user_exam=cid_user_exam,
status="submitted",
extra="manual submission",
) )
return HttpResponse("True") return HttpResponse("True")
@@ -2012,7 +2076,9 @@ def collection_take_overview(
answer_count = 0 answer_count = 0
for cd in case_details: for cd in case_details:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case) self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
if cd in answer_question_map and (answer_question_map[cd].answer or answer_question_map[cd].json_answer): if cd in answer_question_map and (
answer_question_map[cd].answer or answer_question_map[cd].json_answer
):
question_answer_tuples.append((cd, answer_question_map[cd], self_review)) question_answer_tuples.append((cd, answer_question_map[cd], self_review))
answer_count += 1 answer_count += 1
else: else:
@@ -2192,8 +2258,6 @@ def collection_case_view_take(
for series in prior.prior_case.get_series(): for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text)) series_to_load.append((series, True, prior.relation_text))
previous = case_number > 0 previous = case_number > 0
next = case_number < (case_count - 1) next = case_number < (case_count - 1)
@@ -2317,6 +2381,7 @@ def use_dates_as_descriptions(request, pk):
return HttpResponse("Done") return HttpResponse("Done")
def collection_case_dicom_json(request, exam_id, case_id): def collection_case_dicom_json(request, exam_id, case_id):
case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id) case_detail = CaseDetail.objects.get(case=case_id, collection=exam_id)
@@ -2715,6 +2780,7 @@ class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView)
context["collection"] = context["object"] context["collection"] = context["object"]
return context return context
class ExamGroupsUpdate(ExamGroupsUpdateBase): class ExamGroupsUpdate(ExamGroupsUpdateBase):
model = CaseCollection model = CaseCollection
form_class = ExamGroupsForm form_class = ExamGroupsForm
@@ -2819,6 +2885,7 @@ def collection_question_schemas(request, exam_id: int):
}, },
) )
def collection_reset_answers(request, exam_id: int): def collection_reset_answers(request, exam_id: int):
if request.htmx: if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
@@ -2834,7 +2901,6 @@ def collection_reset_answers(request, exam_id: int):
user_answers = case.userreportanswer_set.all() user_answers = case.userreportanswer_set.all()
user_answers.delete() user_answers.delete()
# User statuses # User statuses
collection.exam_user_status.all().delete() collection.exam_user_status.all().delete()
@@ -2842,6 +2908,5 @@ def collection_reset_answers(request, exam_id: int):
collection.cid_users.all().delete() collection.cid_users.all().delete()
return HttpResponse("Success") return HttpResponse("Success")
else: else:
raise Http404("Invalid request") raise Http404("Invalid request")
@@ -0,0 +1,21 @@
# Generated by Django 5.0.2 on 2024-09-23 12:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0017_cidusergroup_open_access_userusergroup_open_access_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='supervisor',
name='user',
field=models.OneToOneField(blank=True, help_text='If the supervisor has an account on the test system it can be associated here', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
+7 -3
View File
@@ -531,7 +531,7 @@ class SeriesBase(models.Model):
size = 0 size = 0
for i in images: for i in images:
size += i.image.size size += i.get_file_size()
return size return size
@@ -997,7 +997,6 @@ class ExamBase(ExamOrCollectionGenericBase):
return User.objects.filter(pk__in=user_ids) return User.objects.filter(pk__in=user_ids)
def generate_user_report(self, user): def generate_user_report(self, user):
exam_text = [f"Candidate {user.first_name} [{user.email}]"] exam_text = [f"Candidate {user.first_name} [{user.email}]"]
@@ -1478,6 +1477,12 @@ class CidUserExam(models.Model):
return f"{self.exam} / {user}: {start_time} {end_time}" return f"{self.exam} / {user}: {start_time} {end_time}"
def get_user_name(self) -> str:
if self.cid_user is None:
return self.user_user.username
else:
return "CID" + str(self.cid_user.cid)
CID_GROUP_EXAMS = ( CID_GROUP_EXAMS = (
("SBAs", "sba_cid_user_groups"), ("SBAs", "sba_cid_user_groups"),
@@ -1504,7 +1509,6 @@ class BaseUserGroup(models.Model):
class CidUserGroup(BaseUserGroup): class CidUserGroup(BaseUserGroup):
def __str__(self) -> str: def __str__(self) -> str:
return str(self.name) return str(self.name)
+2 -2
View File
@@ -747,7 +747,7 @@ def accounts_bulk_create_check(request):
supervisor = Supervisor.objects.filter(email=user["supervisor_email"]) supervisor = Supervisor.objects.filter(email=user["supervisor_email"])
if not supervisor: if not supervisor:
return HttpResponse("Ok, supervisor will be created") return HttpResponse("Ok, supervisor does not exist so will be created")
if supervisor.count() > 1: if supervisor.count() > 1:
return HttpResponse(format_html("<span class='error'>{}</span>", "More than one supervisor with that email")) return HttpResponse(format_html("<span class='error'>{}</span>", "More than one supervisor with that email"))
@@ -793,7 +793,7 @@ def accounts_bulk_create(request):
print(user) print(user)
user_dict = { user_dict = {
"username": user["email"], "username": user["email"],
"first_name": user["email"], "first_name": user["first_name"],
"last_name": user["last_name"], "last_name": user["last_name"],
"email": user["email"], "email": user["email"],
"password": secrets.token_hex(nbytes=16), "password": secrets.token_hex(nbytes=16),