Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ env.bak/
venv.bak/
.env.dev
.env.prod
.env-zip-dgx

# Django
*.log
Expand Down Expand Up @@ -200,3 +201,6 @@ ANALYSIS.md
MODULE_DESIGN.md
Trace-*.json
.tmp_trace_*.tsv
Makefile
CLAUDE.md
.code-review-graph
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \
ca-certificates \
gnupg \
lsb-release \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*

# Install Docker CLI
Expand Down
15 changes: 14 additions & 1 deletion common/job_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def _sanitize_queue_name(name: Optional[str], *, default: str) -> str:

def _project_slug_for_job(job: Any) -> Optional[str]:
try:
if getattr(job, "domain", "") == "brain":
domain = getattr(job, "domain", "")

if domain == "brain":
patient = getattr(job, "brain_patient", None)
if patient is None:
voice_caption = getattr(job, "brain_voice_caption", None)
Expand All @@ -28,6 +30,17 @@ def _project_slug_for_job(job: Any) -> Optional[str]:
)
return "brain" if patient is not None else None

if domain == "laparoscopy":
patient = getattr(job, "laparoscopy_patient", None)
if patient is None:
voice_caption = getattr(job, "laparoscopy_voice_caption", None)
patient = (
getattr(voice_caption, "patient", None)
if voice_caption is not None
else None
)
return "laparoscopy" if patient is not None else None

patient = getattr(job, "patient", None)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Questa change mi ha fatto notare che "maxillo" non e' esplicito qui. Riusciresti a correggere? Oppure creo io una issue per aumentare di poco le probabilita' che non ci dimentichiamo

if patient is None:
voice_caption = getattr(job, "voice_caption", None)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 5.2.4 on 2026-05-04 09:08

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('common', '0023_rename_common_file_domain_22309d_idx_maxillo_fil_domain_760eb4_idx_and_more'),
('laparoscopy', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='fileregistry',
name='laparoscopy_patient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='laparoscopy.patient'),
),
migrations.AddField(
model_name='fileregistry',
name='laparoscopy_voice_caption',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='laparoscopy.voicecaption'),
),
migrations.AddField(
model_name='job',
name='laparoscopy_patient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='laparoscopy.patient'),
),
migrations.AddField(
model_name='job',
name='laparoscopy_voice_caption',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='laparoscopy.voicecaption'),
),
migrations.AddField(
model_name='processingjob',
name='laparoscopy_patient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='processing_jobs', to='laparoscopy.patient'),
),
migrations.AddField(
model_name='processingjob',
name='laparoscopy_voice_caption',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='processing_jobs', to='laparoscopy.voicecaption'),
),
migrations.AlterField(
model_name='fileregistry',
name='domain',
field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20),
),
migrations.AlterField(
model_name='fileregistry',
name='file_type',
field=models.CharField(choices=[('cbct_raw', 'CBCT Raw'), ('cbct_processed', 'CBCT Processed'), ('ios_raw_upper', 'IOS Raw Upper'), ('ios_raw_lower', 'IOS Raw Lower'), ('ios_processed_upper', 'IOS Processed Upper'), ('ios_processed_lower', 'IOS Processed Lower'), ('audio_raw', 'Audio Raw'), ('audio_processed', 'Audio Processed Text'), ('bite_classification', 'Bite Classification Results'), ('rgb_image', 'RGB Image'), ('volume_raw', 'Volume Raw'), ('volume_processed', 'Volume Processed'), ('image_raw', 'Image Raw'), ('image_processed', 'Image Processed'), ('generic_raw', 'Generic Raw'), ('generic_processed', 'Generic Processed'), ('braintumor_mri_t1_raw', 'Brain MRI T1 Raw'), ('braintumor_mri_t1_processed', 'Brain MRI T1 Processed'), ('braintumor_mri_t1c_raw', 'Brain MRI T1c Raw'), ('braintumor_mri_t1c_processed', 'Brain MRI T1c Processed'), ('braintumor_mri_t2_raw', 'Brain MRI T2 Raw'), ('braintumor_mri_t2_processed', 'Brain MRI T2 Processed'), ('braintumor_mri_flair_raw', 'Brain MRI FLAIR Raw'), ('braintumor_mri_flair_processed', 'Brain MRI FLAIR Processed'), ('intraoral_raw', 'Intraoral Photographs Raw'), ('intraoral_processed', 'Intraoral Photographs Processed'), ('teleradiography_raw', 'Teleradiography Raw'), ('teleradiography_processed', 'Teleradiography Processed'), ('panoramic_raw', 'panoramic Raw'), ('panoramic_processed', 'panoramic Processed'), ('video_raw', 'Video Raw'), ('video_processed', 'Video Processed')], max_length=255),
),
migrations.AlterField(
model_name='job',
name='domain',
field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20),
),
migrations.AlterField(
model_name='processingjob',
name='domain',
field=models.CharField(choices=[('maxillo', 'Maxillo'), ('brain', 'Brain'), ('laparoscopy', 'Laparoscopy')], default='maxillo', max_length=20),
),
]
12 changes: 12 additions & 0 deletions common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class Job(models.Model):
DOMAIN_CHOICES = [
('maxillo', 'Maxillo'),
('brain', 'Brain'),
('laparoscopy', 'Laparoscopy'),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Atra cosa che sarebbe meglio segnarsi per correggerlo, sta roba e' ripetuta troppe volte in questo file, cosi' come altri campi

]

STATUS_CHOICES = [
Expand All @@ -177,8 +178,10 @@ class Job(models.Model):
domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo')
patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)
brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)
laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)
voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)
brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)
laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='jobs', null=True, blank=True)

# IO
input_file_path = models.CharField(max_length=500, help_text='Primary input object key', blank=True)
Expand Down Expand Up @@ -289,6 +292,7 @@ class ProcessingJob(models.Model):
DOMAIN_CHOICES = [
('maxillo', 'Maxillo'),
('brain', 'Brain'),
('laparoscopy', 'Laparoscopy'),
]

JOB_TYPE_CHOICES = [
Expand All @@ -314,8 +318,10 @@ class ProcessingJob(models.Model):
domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo')
patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)
brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)
laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)
voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)
brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)
laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='processing_jobs', null=True, blank=True)

# File paths
input_file_path = models.CharField(max_length=500, help_text='Input object key')
Expand Down Expand Up @@ -430,6 +436,7 @@ class FileRegistry(models.Model):
DOMAIN_CHOICES = [
('maxillo', 'Maxillo'),
('brain', 'Brain'),
('laparoscopy', 'Laparoscopy'),
]

FILE_TYPE_CHOICES = [
Expand Down Expand Up @@ -465,6 +472,9 @@ class FileRegistry(models.Model):
('teleradiography_processed', 'Teleradiography Processed'),
('panoramic_raw', 'panoramic Raw'),
('panoramic_processed', 'panoramic Processed'),
# Generic video modality (used by laparoscopy and any future video domain)
('video_raw', 'Video Raw'),
('video_processed', 'Video Processed'),
]

file_type = models.CharField(max_length=255, choices=FILE_TYPE_CHOICES)
Expand All @@ -477,8 +487,10 @@ class FileRegistry(models.Model):
domain = models.CharField(max_length=20, choices=DOMAIN_CHOICES, default='maxillo')
patient = models.ForeignKey('maxillo.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
brain_patient = models.ForeignKey('brain.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
laparoscopy_patient = models.ForeignKey('laparoscopy.Patient', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
voice_caption = models.ForeignKey('maxillo.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
brain_voice_caption = models.ForeignKey('brain.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
laparoscopy_voice_caption = models.ForeignKey('laparoscopy.VoiceCaption', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
processing_job = models.ForeignKey('common.Job', on_delete=models.CASCADE, related_name='files', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
metadata = models.JSONField(default=dict, blank=True, help_text='Additional file metadata')
Expand Down
24 changes: 24 additions & 0 deletions common/object_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def __init__(
config=Config(
s3={"addressing_style": self.addressing_style or "path"},
retries={"max_attempts": 3, "mode": "standard"},
# Garage can advertise flexible checksums that botocore then
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Questa roba perche' e' apparsa? leggo an optimization for video streams ma non sono sicurissimo di aver capito. Se riesci fai un check che sia veramente necessaria e fai in modo che il commento sopra sia un po' piu' chiaro

# rejects on plain get_object reads. Keep validation only for
# operations that explicitly require it.
# An optimization for video stream
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
),
)

Expand Down Expand Up @@ -164,6 +170,24 @@ def get(self, key: str) -> Tuple[BinaryIO, ObjectInfo]:
)
return resp["Body"], info

def get_range(self, key: str, byte_range: str) -> Tuple[BinaryIO, ObjectInfo]:
"""Fetch a byte range of an object (e.g. byte_range='bytes=0-1023')."""
key_n = self.normalize_key(key)
try:
resp = self._client.get_object(Bucket=self.bucket, Key=key_n, Range=byte_range)
except ClientError as exc:
code = self._client_error_code(exc)
if code in {"NoSuchKey", "404", "NotFound"}:
raise FileNotFoundError(key) from exc
raise ObjectStorageError(str(exc)) from exc
info = ObjectInfo(
key=key,
content_length=resp.get("ContentLength"),
content_type=resp.get("ContentType"),
etag=(resp.get("ETag") or "").strip('"') or None,
)
return resp["Body"], info

def iter_bytes(
self, key: str, *, chunk_size: int = 1024 * 1024
) -> Generator[bytes, None, None]:
Expand Down
Empty file added laparoscopy/__init__.py
Empty file.
104 changes: 104 additions & 0 deletions laparoscopy/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from django.contrib import admin
from django.utils.html import format_html

from .models import (
Classification,
Dataset,
Export,
Folder,
Patient,
QuadrantClassificationMarker,
QuadrantType,
Tag,
VoiceCaption,
)


class QuadrantClassificationMarkerInline(admin.TabularInline):
model = QuadrantClassificationMarker
extra = 0
fields = ['time_ms', 'quadrant_type', 'created_by', 'updated_by', 'updated_at']
readonly_fields = ['created_by', 'updated_by', 'updated_at']
ordering = ['time_ms', 'id']


@admin.register(Dataset)
class DatasetAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at', 'created_by']
search_fields = ['name', 'description']


@admin.register(Folder)
class FolderAdmin(admin.ModelAdmin):
list_display = ['name', 'parent', 'created_at', 'created_by']
search_fields = ['name']


@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at']
search_fields = ['name']


@admin.register(Patient)
class PatientAdmin(admin.ModelAdmin):
list_display = ['patient_id', 'name', 'visibility', 'folder', 'uploaded_at', 'uploaded_by']
list_filter = ['visibility', 'uploaded_at']
search_fields = ['patient_id', 'name']
inlines = [QuadrantClassificationMarkerInline]


@admin.register(Classification)
class ClassificationAdmin(admin.ModelAdmin):
list_display = ['id', 'patient', 'classifier', 'timestamp']
list_filter = ['classifier', 'timestamp']


@admin.register(VoiceCaption)
class VoiceCaptionAdmin(admin.ModelAdmin):
list_display = ['id', 'patient', 'user', 'modality', 'processing_status', 'created_at']
list_filter = ['modality', 'processing_status', 'created_at']


@admin.register(Export)
class ExportAdmin(admin.ModelAdmin):
list_display = ['id', 'user', 'status', 'patient_count', 'file_size', 'created_at', 'completed_at']
list_filter = ['status', 'share_mode', 'created_at', 'completed_at']
search_fields = ['id', 'user__username', 'query_summary', 'file_path', 'share_token']
readonly_fields = ['created_at', 'started_at', 'completed_at', 'shared_at']


@admin.register(QuadrantType)
class QuadrantTypeAdmin(admin.ModelAdmin):
list_display = ['id', 'project', 'name', 'color_preview', 'color', 'order', 'marker_count']
list_filter = ['project']
search_fields = ['name', 'project__name', 'project__slug']
ordering = ['project__name', 'order', 'name']

@admin.display(description='Color')
def color_preview(self, obj):
return format_html(
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:{};border:1px solid #ccc;"></span>',
obj.color,
)

@admin.display(description='Markers')
def marker_count(self, obj):
return obj.markers.count()


@admin.register(QuadrantClassificationMarker)
class QuadrantClassificationMarkerAdmin(admin.ModelAdmin):
list_display = ['id', 'patient', 'patient_name', 'quadrant_type', 'time_seconds', 'created_by', 'updated_by', 'updated_at']
list_filter = ['quadrant_type', 'patient__visibility', 'created_at', 'updated_at']
search_fields = ['patient__patient_id', 'patient__name', 'quadrant_type__name']
autocomplete_fields = ['patient', 'quadrant_type', 'created_by', 'updated_by']
ordering = ['patient_id', 'time_ms', 'id']

@admin.display(description='Patient Name')
def patient_name(self, obj):
return obj.patient.name

@admin.display(description='Time (s)', ordering='time_ms')
def time_seconds(self, obj):
return f'{obj.time_ms / 1000:.3f}'
6 changes: 6 additions & 0 deletions laparoscopy/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class LaparoscopyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'laparoscopy'
Loading