Add downsample series functionality and related UI updates
- Implemented asynchronous downsample task for series with progress tracking. - Added new URL endpoint for downsample status. - Updated Series model to include source_series_instance_uid. - Enhanced case display template with new buttons for series actions. - Added password reset functionality in user profile with appropriate alerts. - Created migration for new source_series_instance_uid field in Series model.
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="series",
|
||||||
|
name="source_series_instance_uid",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1132,6 +1132,7 @@ class Series(SeriesBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
|
series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
|
||||||
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
|
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
|
||||||
|
|
||||||
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
||||||
|
|||||||
+101
-2
@@ -92,6 +92,106 @@ from django.core.cache import cache
|
|||||||
import copy
|
import copy
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
@task(takes_context=True)
|
||||||
|
def series_downsample_task(context, series_id, user_id, downsample_pct):
|
||||||
|
"""Downsample a series asynchronously while preserving hash continuity."""
|
||||||
|
from loguru import logger
|
||||||
|
from atlas import views as atlas_views
|
||||||
|
|
||||||
|
progress_key = f"series_downsample_progress:{context.task_result.id}"
|
||||||
|
cache.set(
|
||||||
|
progress_key,
|
||||||
|
{"current": 0, "total": 0, "message": "Preparing downsample..."},
|
||||||
|
timeout=60 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
user = get_object_or_404(User, pk=user_id)
|
||||||
|
|
||||||
|
if not series.check_user_can_edit(user):
|
||||||
|
raise PermissionError("Permission denied")
|
||||||
|
|
||||||
|
images = list(series.get_images())
|
||||||
|
total_images = len(images)
|
||||||
|
replacement_series_uid = generate_uid()
|
||||||
|
source_series_uid = series.series_instance_uid
|
||||||
|
updated_images = []
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
progress_key,
|
||||||
|
{"current": 0, "total": total_images, "message": "Downsample started"},
|
||||||
|
timeout=60 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, image in enumerate(images, start=1):
|
||||||
|
ds = atlas_views._read_series_image_dataset(image)
|
||||||
|
if ds is None:
|
||||||
|
cache.set(
|
||||||
|
progress_key,
|
||||||
|
{"current": idx, "total": total_images, "message": f"Skipping unreadable image ({idx}/{total_images})"},
|
||||||
|
timeout=60 * 60,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
original_hash = image.image_blake3_hash
|
||||||
|
ds_out = atlas_views._downsample_dicom_dataset(
|
||||||
|
ds,
|
||||||
|
int(downsample_pct),
|
||||||
|
series_instance_uid=replacement_series_uid,
|
||||||
|
source_series_instance_uid=source_series_uid,
|
||||||
|
series_description=series.description,
|
||||||
|
)
|
||||||
|
out_io = io.BytesIO()
|
||||||
|
ds_out.save_as(out_io, write_like_original=False)
|
||||||
|
out_io.seek(0)
|
||||||
|
|
||||||
|
image.image.save(
|
||||||
|
f"downsample_{downsample_pct}_{image.pk}.dcm",
|
||||||
|
ContentFile(out_io.getvalue()),
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
image.image_blake3_hash = original_hash
|
||||||
|
image.save(update_fields=["image", "image_blake3_hash"])
|
||||||
|
updated_images.append(image.pk)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
progress_key,
|
||||||
|
{
|
||||||
|
"current": idx,
|
||||||
|
"total": total_images,
|
||||||
|
"message": f"Downsampling image {idx}/{total_images}",
|
||||||
|
},
|
||||||
|
timeout=60 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated_images:
|
||||||
|
series.modified = Series.SeriesModifiedChocies.RE
|
||||||
|
series.source_series_instance_uid = source_series_uid
|
||||||
|
series.series_instance_uid = replacement_series_uid
|
||||||
|
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
progress_key,
|
||||||
|
{
|
||||||
|
"current": total_images,
|
||||||
|
"total": total_images,
|
||||||
|
"message": "Downsample complete",
|
||||||
|
},
|
||||||
|
timeout=10 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"series_id": series.pk,
|
||||||
|
"downsample_pct": int(downsample_pct),
|
||||||
|
"updated_images": len(updated_images),
|
||||||
|
"series_instance_uid": replacement_series_uid,
|
||||||
|
"source_series_instance_uid": source_series_uid,
|
||||||
|
}
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def push_case_to_cimar_task(case_id):
|
def push_case_to_cimar_task(case_id):
|
||||||
"""Sends an email when the feedback form has been submitted."""
|
"""Sends an email when the feedback form has been submitted."""
|
||||||
@@ -389,8 +489,6 @@ def series_reconstruct_task(
|
|||||||
|
|
||||||
for plane_norm, cfg in plane_builders.items():
|
for plane_norm, cfg in plane_builders.items():
|
||||||
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
|
||||||
recon_series.series_instance_uid = generate_uid()
|
|
||||||
recon_series.save(update_fields=["series_instance_uid"])
|
|
||||||
|
|
||||||
for idx in range(int(cfg["count"])):
|
for idx in range(int(cfg["count"])):
|
||||||
arr2d = cfg["slice_fn"](idx)
|
arr2d = cfg["slice_fn"](idx)
|
||||||
@@ -403,6 +501,7 @@ def series_reconstruct_task(
|
|||||||
atlas_views._stamp_derived_dataset(
|
atlas_views._stamp_derived_dataset(
|
||||||
ds_new,
|
ds_new,
|
||||||
series_instance_uid=recon_series.series_instance_uid,
|
series_instance_uid=recon_series.series_instance_uid,
|
||||||
|
source_series_instance_uid=series.series_instance_uid,
|
||||||
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
series_description=recon_series.description or f"Recon {plane_norm.title()}",
|
||||||
derivation_description=(
|
derivation_description=(
|
||||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
draggable="true">
|
draggable="true">
|
||||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||||
<div class="series-card-shell">
|
<div class="series-card-shell">
|
||||||
<button type="button"
|
<div type="button"
|
||||||
class="series-card-main"
|
class="series-card-main"
|
||||||
data-series-action="details"
|
data-series-action="details"
|
||||||
data-series-id="{{series.pk}}"
|
data-series-id="{{series.pk}}"
|
||||||
@@ -37,32 +37,31 @@
|
|||||||
<span class="series-card-image-count">{{ series.visible_image_count }} image{{ series.visible_image_count|pluralize }}</span>
|
<span class="series-card-image-count">{{ series.visible_image_count }} image{{ series.visible_image_count|pluralize }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="series-card-description">{{ series.description|default:"No description" }}</div>
|
<div class="series-card-description">{{ series.description|default:"No description" }}</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-info btn-sm series-open-primary-btn"
|
||||||
|
title="View series in viewer"
|
||||||
|
data-series-id="{{series.pk}}"
|
||||||
|
data-series-name="{{ series|escape }}">
|
||||||
|
<i class="bi bi-eye"></i> View1
|
||||||
|
</button>
|
||||||
|
<div class="series-card-inline-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-sm select-series-btn"
|
||||||
|
style="display: none;"
|
||||||
|
data-series-id="{{series.pk}}">
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
|
||||||
|
title="View series in a new pane"
|
||||||
|
data-series-id="{{series.pk}}"
|
||||||
|
data-series-name="{{ series|escape }}"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true">
|
||||||
|
<i class="bi bi-layout-split"></i> View in new pane
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="series-card-controls">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-primary btn-sm select-series-btn"
|
|
||||||
style="display: none;"
|
|
||||||
data-series-id="{{series.pk}}">
|
|
||||||
Select
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-info btn-sm series-open-primary-btn"
|
|
||||||
title="View series in viewer"
|
|
||||||
data-series-id="{{series.pk}}"
|
|
||||||
data-series-name="{{ series|escape }}">
|
|
||||||
<i class="bi bi-eye"></i> View
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
|
|
||||||
title="View series in a new pane"
|
|
||||||
data-series-id="{{series.pk}}"
|
|
||||||
data-series-name="{{ series|escape }}"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-hidden="true">
|
|
||||||
<i class="bi bi-layout-split"></i> View in new pane
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -2122,7 +2121,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.15rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
.series-card-heading {
|
.series-card-heading {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -2145,14 +2144,14 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.series-card-controls {
|
.series-card-inline-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-top: auto;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
.series-card-controls .btn {
|
.series-card-inline-actions .btn {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
|
||||||
padding: 0.3rem 0.4rem;
|
padding: 0.3rem 0.4rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,11 @@ urlpatterns = [
|
|||||||
views.series_reconstruct_status_htmx,
|
views.series_reconstruct_status_htmx,
|
||||||
name="series_reconstruct_status",
|
name="series_reconstruct_status",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"series/<int:series_id>/downsample/status/<str:task_id>/",
|
||||||
|
views.series_downsample_status_htmx,
|
||||||
|
name="series_downsample_status",
|
||||||
|
),
|
||||||
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
||||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||||
|
|||||||
+105
-38
@@ -165,7 +165,7 @@ from .filters import (
|
|||||||
NormalCaseFilter,
|
NormalCaseFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .tasks import push_case_to_cimar_task, series_reconstruct_task
|
from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
|
||||||
try:
|
try:
|
||||||
from django_tasks import TaskResultStatus
|
from django_tasks import TaskResultStatus
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -313,6 +313,7 @@ def _stamp_derived_dataset(
|
|||||||
ds,
|
ds,
|
||||||
*,
|
*,
|
||||||
series_instance_uid=None,
|
series_instance_uid=None,
|
||||||
|
source_series_instance_uid=None,
|
||||||
series_description=None,
|
series_description=None,
|
||||||
derivation_description=None,
|
derivation_description=None,
|
||||||
image_type=None,
|
image_type=None,
|
||||||
@@ -322,6 +323,8 @@ def _stamp_derived_dataset(
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
date_str = now.strftime("%Y%m%d")
|
date_str = now.strftime("%Y%m%d")
|
||||||
time_str = now.strftime("%H%M%S.%f")[:13]
|
time_str = now.strftime("%H%M%S.%f")[:13]
|
||||||
|
source_sop_instance_uid = getattr(ds, "SOPInstanceUID", None)
|
||||||
|
source_sop_class_uid = getattr(ds, "SOPClassUID", None)
|
||||||
|
|
||||||
if regenerate_sop_instance_uid:
|
if regenerate_sop_instance_uid:
|
||||||
ds.SOPInstanceUID = generate_uid()
|
ds.SOPInstanceUID = generate_uid()
|
||||||
@@ -335,6 +338,18 @@ def _stamp_derived_dataset(
|
|||||||
if derivation_description:
|
if derivation_description:
|
||||||
ds.DerivationDescription = derivation_description
|
ds.DerivationDescription = derivation_description
|
||||||
|
|
||||||
|
if source_series_instance_uid:
|
||||||
|
series_ref = pydicom.dataset.Dataset()
|
||||||
|
series_ref.SeriesInstanceUID = str(source_series_instance_uid)
|
||||||
|
ds.ReferencedSeriesSequence = pydicom.sequence.Sequence([series_ref])
|
||||||
|
|
||||||
|
if source_sop_instance_uid:
|
||||||
|
source_ref = pydicom.dataset.Dataset()
|
||||||
|
if source_sop_class_uid:
|
||||||
|
source_ref.ReferencedSOPClassUID = str(source_sop_class_uid)
|
||||||
|
source_ref.ReferencedSOPInstanceUID = str(source_sop_instance_uid)
|
||||||
|
ds.SourceImageSequence = pydicom.sequence.Sequence([source_ref])
|
||||||
|
|
||||||
ds.ContentDate = date_str
|
ds.ContentDate = date_str
|
||||||
ds.ContentTime = time_str
|
ds.ContentTime = time_str
|
||||||
ds.InstanceCreationDate = date_str
|
ds.InstanceCreationDate = date_str
|
||||||
@@ -347,7 +362,14 @@ def _stamp_derived_dataset(
|
|||||||
return ds
|
return ds
|
||||||
|
|
||||||
|
|
||||||
def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, series_description=None):
|
def _downsample_dicom_dataset(
|
||||||
|
ds,
|
||||||
|
reduction_pct,
|
||||||
|
*,
|
||||||
|
series_instance_uid=None,
|
||||||
|
source_series_instance_uid=None,
|
||||||
|
series_description=None,
|
||||||
|
):
|
||||||
if reduction_pct <= 0 or reduction_pct >= 100:
|
if reduction_pct <= 0 or reduction_pct >= 100:
|
||||||
raise ValueError("Downsample percentage must be between 1 and 99")
|
raise ValueError("Downsample percentage must be between 1 and 99")
|
||||||
|
|
||||||
@@ -365,6 +387,7 @@ def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, se
|
|||||||
_stamp_derived_dataset(
|
_stamp_derived_dataset(
|
||||||
ds,
|
ds,
|
||||||
series_instance_uid=series_instance_uid,
|
series_instance_uid=series_instance_uid,
|
||||||
|
source_series_instance_uid=source_series_instance_uid,
|
||||||
series_description=series_description,
|
series_description=series_description,
|
||||||
derivation_description=derivation_description,
|
derivation_description=derivation_description,
|
||||||
image_type=["DERIVED", "SECONDARY"],
|
image_type=["DERIVED", "SECONDARY"],
|
||||||
@@ -399,6 +422,7 @@ def _create_series_derivative(source_series, description_suffix):
|
|||||||
derived.pk = None
|
derived.pk = None
|
||||||
derived.id = None
|
derived.id = None
|
||||||
derived.series_instance_uid = generate_uid()
|
derived.series_instance_uid = generate_uid()
|
||||||
|
derived.source_series_instance_uid = source_series.series_instance_uid
|
||||||
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
|
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
|
||||||
derived.modified = Series.SeriesModifiedChocies.RE
|
derived.modified = Series.SeriesModifiedChocies.RE
|
||||||
derived.save()
|
derived.save()
|
||||||
@@ -675,6 +699,7 @@ def series_optimize_htmx(request, series_id):
|
|||||||
ds,
|
ds,
|
||||||
downsample_pct,
|
downsample_pct,
|
||||||
series_instance_uid=preview_series.series_instance_uid,
|
series_instance_uid=preview_series.series_instance_uid,
|
||||||
|
source_series_instance_uid=series.series_instance_uid,
|
||||||
series_description=preview_series.description,
|
series_description=preview_series.description,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -714,44 +739,22 @@ def series_optimize_htmx(request, series_id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
downsampled = []
|
task_result = series_downsample_task.enqueue(
|
||||||
replacement_series_uid = generate_uid()
|
series_id=series.pk,
|
||||||
for image in full_series_images:
|
user_id=request.user.pk,
|
||||||
ds = _read_series_image_dataset(image)
|
downsample_pct=downsample_pct,
|
||||||
if ds is None:
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
original_hash = image.image_blake3_hash
|
|
||||||
ds_out = _downsample_dicom_dataset(
|
|
||||||
ds,
|
|
||||||
downsample_pct,
|
|
||||||
series_instance_uid=replacement_series_uid,
|
|
||||||
series_description=series.description,
|
|
||||||
)
|
|
||||||
out_io = io.BytesIO()
|
|
||||||
ds_out.save_as(out_io, write_like_original=False)
|
|
||||||
out_io.seek(0)
|
|
||||||
|
|
||||||
image.image.save(
|
|
||||||
f"downsample_{downsample_pct}_{image.pk}.dcm",
|
|
||||||
ContentFile(out_io.getvalue()),
|
|
||||||
save=False,
|
|
||||||
)
|
|
||||||
# Preserve original hash for duplicate detection continuity.
|
|
||||||
image.image_blake3_hash = original_hash
|
|
||||||
image.save(update_fields=["image", "image_blake3_hash"])
|
|
||||||
downsampled.append(image.pk)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
|
|
||||||
|
|
||||||
if downsampled:
|
|
||||||
series.modified = Series.SeriesModifiedChocies.RE
|
|
||||||
series.series_instance_uid = replacement_series_uid
|
|
||||||
series.save(update_fields=["modified", "series_instance_uid"])
|
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'
|
(
|
||||||
|
'<div class="alert alert-info mb-2">'
|
||||||
|
'Downsample queued. This runs in the background to avoid request timeout.'
|
||||||
|
'</div>'
|
||||||
|
f'<div id="downsample-task-status" hx-get="{reverse("atlas:series_downsample_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
|
||||||
|
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
|
||||||
|
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
|
||||||
|
'<span class="small text-muted">Starting downsample task...</span></div></div>'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if operation == "reconstruct":
|
if operation == "reconstruct":
|
||||||
@@ -881,6 +884,70 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def series_downsample_status_htmx(request, series_id, task_id):
|
||||||
|
"""HTMX endpoint returning progress for a queued series downsample task."""
|
||||||
|
series = get_object_or_404(Series, pk=series_id)
|
||||||
|
target_id = (request.GET.get("target_id") or "downsample-task-status").strip() or "downsample-task-status"
|
||||||
|
target_id = escape(target_id)
|
||||||
|
|
||||||
|
if not series.check_user_can_edit(request.user):
|
||||||
|
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
|
||||||
|
|
||||||
|
try:
|
||||||
|
task_result = series_downsample_task.get_result(task_id)
|
||||||
|
except NotImplementedError:
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
|
||||||
|
|
||||||
|
task_result.refresh()
|
||||||
|
state = task_result.status
|
||||||
|
|
||||||
|
poll_url = reverse("atlas:series_downsample_status", kwargs={"series_id": series.pk, "task_id": task_id})
|
||||||
|
progress = cache.get(f"series_downsample_progress:{task_id}") or {}
|
||||||
|
current = int(progress.get("current", 0) or 0)
|
||||||
|
total = int(progress.get("total", 0) or 0)
|
||||||
|
message = escape(str(progress.get("message", "Downsample is running...")))
|
||||||
|
pct = int((current / total) * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
|
||||||
|
return HttpResponse(
|
||||||
|
(
|
||||||
|
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
|
||||||
|
f'<div class="small text-muted mb-1">{message}</div>'
|
||||||
|
'<div class="progress" style="height: 10px;">'
|
||||||
|
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
|
||||||
|
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
|
||||||
|
'</div>'
|
||||||
|
f'<div class="small text-muted mt-1">{current}/{total} images processed</div>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if state == TaskResultStatus.SUCCESSFUL:
|
||||||
|
payload = task_result.return_value or {}
|
||||||
|
updated = int(payload.get("updated_images", 0) or 0)
|
||||||
|
downsample_pct = escape(str(payload.get("downsample_pct", "")))
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="{target_id}" class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{updated}</strong> images while preserving original hashes.</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if state == TaskResultStatus.FAILED:
|
||||||
|
err = "Unknown failure"
|
||||||
|
if task_result.errors:
|
||||||
|
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="{target_id}" class="alert alert-danger mb-0">Downsample failed: {err}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_is_atlas_editor
|
@user_is_atlas_editor
|
||||||
def task_overview(request):
|
def task_overview(request):
|
||||||
|
|||||||
+26
-10
@@ -5855,15 +5855,31 @@ def create_user(request, context=None, trainee: bool = False):
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
try:
|
||||||
|
username = form.cleaned_data["username"].strip().lower()
|
||||||
|
existing_user = User.objects.filter(
|
||||||
|
Q(username__iexact=username) | Q(email__iexact=username)
|
||||||
|
).first()
|
||||||
|
|
||||||
user_dict = {
|
user_dict = {
|
||||||
"username": request.POST["username"],
|
"username": username,
|
||||||
"first_name": request.POST["first_name"],
|
"first_name": form.cleaned_data["first_name"],
|
||||||
"last_name": request.POST["last_name"],
|
"last_name": form.cleaned_data["last_name"],
|
||||||
"email": request.POST["username"],
|
"email": username,
|
||||||
"password": secrets.token_hex(nbytes=16),
|
"password": secrets.token_hex(nbytes=16),
|
||||||
}
|
}
|
||||||
|
|
||||||
new_user = User.objects.create_user(**user_dict)
|
with transaction.atomic():
|
||||||
|
if existing_user is not None:
|
||||||
|
existing_user.username = user_dict["username"]
|
||||||
|
existing_user.email = user_dict["email"]
|
||||||
|
existing_user.first_name = user_dict["first_name"]
|
||||||
|
existing_user.last_name = user_dict["last_name"]
|
||||||
|
existing_user.save(
|
||||||
|
update_fields=["username", "email", "first_name", "last_name"]
|
||||||
|
)
|
||||||
|
new_user = existing_user
|
||||||
|
else:
|
||||||
|
new_user = User.objects.create_user(**user_dict)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
errors = f"Unable to create account: {error}"
|
errors = f"Unable to create account: {error}"
|
||||||
return render(
|
return render(
|
||||||
@@ -5873,15 +5889,15 @@ def create_user(request, context=None, trainee: bool = False):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_profile = UserProfile.objects.get(user=new_user)
|
user_profile, _ = UserProfile.objects.get_or_create(user=new_user)
|
||||||
user_profile.peninsula_trainee = trainee
|
user_profile.peninsula_trainee = trainee
|
||||||
|
|
||||||
if request.POST["grade"]:
|
if form.cleaned_data.get("grade"):
|
||||||
grade = UserGrades.objects.get(pk=request.POST["grade"])
|
grade = form.cleaned_data["grade"]
|
||||||
user_profile.grade = grade
|
user_profile.grade = grade
|
||||||
|
|
||||||
if "supervisor" in request.POST and request.POST["supervisor"]:
|
if trainee and form.cleaned_data.get("supervisor"):
|
||||||
supervisor = Supervisor.objects.get(pk=request.POST["supervisor"])
|
supervisor = form.cleaned_data["supervisor"]
|
||||||
user_profile.supervisor = supervisor
|
user_profile.supervisor = supervisor
|
||||||
|
|
||||||
user_profile.save()
|
user_profile.save()
|
||||||
|
|||||||
+210
-210
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
from rich.pretty import pprint
|
from rich.pretty import pprint
|
||||||
|
|
||||||
@@ -89,3 +90,53 @@ def test_profile_access_and_management(db, client, create_users, create_cid_mana
|
|||||||
assert BeautifulSoup(update_profile_response.content, "html.parser").find(id="id_registration_number")["value"]
|
assert BeautifulSoup(update_profile_response.content, "html.parser").find(id="id_registration_number")["value"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_user_reuses_existing_account(client, create_cid_manager, django_user_model):
|
||||||
|
existing_user = django_user_model.objects.create_user(
|
||||||
|
username="existing@example.com",
|
||||||
|
email="existing@example.com",
|
||||||
|
password="password123",
|
||||||
|
first_name="Old",
|
||||||
|
last_name="Name",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(create_cid_manager)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("create_user"),
|
||||||
|
{
|
||||||
|
"username": "existing@example.com",
|
||||||
|
"first_name": "Updated",
|
||||||
|
"last_name": "User",
|
||||||
|
"grade": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.url == reverse("accounts_list")
|
||||||
|
|
||||||
|
existing_user.refresh_from_db()
|
||||||
|
assert existing_user.first_name == "Updated"
|
||||||
|
assert existing_user.last_name == "User"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_cid_manager_can_send_password_reset_from_account_profile(client, create_cid_manager, django_user_model):
|
||||||
|
managed_user = django_user_model.objects.create_user(
|
||||||
|
username="managed-user",
|
||||||
|
email="managed@example.com",
|
||||||
|
password="password123",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(create_cid_manager)
|
||||||
|
response = client.post(
|
||||||
|
reverse("account_send_password_reset", kwargs={"slug": managed_user.username}),
|
||||||
|
{"next": reverse("account_profile", kwargs={"slug": managed_user.username})},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "password_reset_action=sent" in response.url
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
assert mail.outbox[0].to == ["managed@example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ urlpatterns = [
|
|||||||
views.account_set_supervisor,
|
views.account_set_supervisor,
|
||||||
name="account_set_supervisor",
|
name="account_set_supervisor",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"accounts/profile/<str:slug>/send-password-reset/",
|
||||||
|
views.account_send_password_reset,
|
||||||
|
name="account_send_password_reset",
|
||||||
|
),
|
||||||
#path("", TemplateView.as_view(template_name="index.html"), name="home"),
|
#path("", TemplateView.as_view(template_name="index.html"), name="home"),
|
||||||
path("", views.index, name="home"),
|
path("", views.index, name="home"),
|
||||||
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
|
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from django.utils.safestring import mark_safe
|
|||||||
|
|
||||||
# from django.contrib.auth.models import User
|
# from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
|
from django.contrib.auth.forms import PasswordResetForm
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@@ -167,6 +168,7 @@ def profile(request):
|
|||||||
"is_cid_user_manager": is_cid_user_manager,
|
"is_cid_user_manager": is_cid_user_manager,
|
||||||
"supervisor_account": supervisor_account,
|
"supervisor_account": supervisor_account,
|
||||||
"supervisor_action": request.GET.get("supervisor_action", ""),
|
"supervisor_action": request.GET.get("supervisor_action", ""),
|
||||||
|
"password_reset_action": request.GET.get("password_reset_action", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +218,7 @@ def account_profile(request, slug):
|
|||||||
"is_cid_user_manager": is_cid_user_manager,
|
"is_cid_user_manager": is_cid_user_manager,
|
||||||
"supervisor_account": supervisor_account,
|
"supervisor_account": supervisor_account,
|
||||||
"supervisor_action": request.GET.get("supervisor_action", ""),
|
"supervisor_action": request.GET.get("supervisor_action", ""),
|
||||||
|
"password_reset_action": request.GET.get("password_reset_action", ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,6 +260,38 @@ def account_set_supervisor(request, slug):
|
|||||||
return redirect(f"{next_url}?supervisor_action=created")
|
return redirect(f"{next_url}?supervisor_action=created")
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@user_is_cid_user_manager
|
||||||
|
def account_send_password_reset(request, slug):
|
||||||
|
"""Send a password reset email for an existing user from their profile page."""
|
||||||
|
user = get_object_or_404(User, username=slug)
|
||||||
|
|
||||||
|
next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username})
|
||||||
|
email = (user.email or user.username or "").strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return redirect(f"{next_url}?password_reset_action=no_email")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return redirect(f"{next_url}?password_reset_action=inactive")
|
||||||
|
|
||||||
|
form = PasswordResetForm({"email": email})
|
||||||
|
if not form.is_valid():
|
||||||
|
return redirect(f"{next_url}?password_reset_action=invalid")
|
||||||
|
|
||||||
|
try:
|
||||||
|
form.save(
|
||||||
|
request=request,
|
||||||
|
use_https=request.is_secure(),
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send password reset email for user {}", user.pk)
|
||||||
|
return redirect(f"{next_url}?password_reset_action=error")
|
||||||
|
|
||||||
|
return redirect(f"{next_url}?password_reset_action=sent")
|
||||||
|
|
||||||
|
|
||||||
@user_passes_test(lambda u: u.is_superuser)
|
@user_passes_test(lambda u: u.is_superuser)
|
||||||
def logs_view(request):
|
def logs_view(request):
|
||||||
"""Display production logs from log.txt with search filtering.
|
"""Display production logs from log.txt with search filtering.
|
||||||
|
|||||||
+20
-2
@@ -104,13 +104,31 @@
|
|||||||
<div class="alert alert-danger py-2" role="alert">Cannot link supervisor account: email is already linked to another user.</div>
|
<div class="alert alert-danger py-2" role="alert">Cannot link supervisor account: email is already linked to another user.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a>
|
{% if password_reset_action == 'sent' %}
|
||||||
|
<div class="alert alert-success py-2" role="alert">Password reset email sent.</div>
|
||||||
|
{% elif password_reset_action == 'no_email' %}
|
||||||
|
<div class="alert alert-warning py-2" role="alert">Cannot send password reset: user has no email address.</div>
|
||||||
|
{% elif password_reset_action == 'inactive' %}
|
||||||
|
<div class="alert alert-warning py-2" role="alert">Cannot send password reset: user account is inactive.</div>
|
||||||
|
{% elif password_reset_action == 'invalid' %}
|
||||||
|
<div class="alert alert-danger py-2" role="alert">Cannot send password reset: email address is invalid.</div>
|
||||||
|
{% elif password_reset_action == 'error' %}
|
||||||
|
<div class="alert alert-danger py-2" role="alert">Password reset email could not be sent.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}" id="password-change">Change password</a>
|
||||||
{% if request.user|has_group:"cid_user_manager" %}
|
{% if request.user|has_group:"cid_user_manager" %}
|
||||||
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
|
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}" id="update-profile-link">Edit profile</a>
|
||||||
|
|
||||||
{% if request.user.is_superuser or is_cid_user_manager %}
|
{% if request.user.is_superuser or is_cid_user_manager %}
|
||||||
|
<form method="post" action="{% url 'account_send_password_reset' user.username %}" class="mb-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="btn btn-outline-warning d-block w-100">Send password reset link</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% if not supervisor_account %}
|
{% if not supervisor_account %}
|
||||||
<form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2">
|
<form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
Reference in New Issue
Block a user