diff --git a/test_tube_scanner/home/locale/en/LC_MESSAGES/django.po b/test_tube_scanner/home/locale/en/LC_MESSAGES/django.po index c76b5fc..754a048 100644 --- a/test_tube_scanner/home/locale/en/LC_MESSAGES/django.po +++ b/test_tube_scanner/home/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-14 17:29+0200\n" +"POT-Creation-Date: 2026-05-15 15:08+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -80,7 +80,7 @@ msgstr "Prerequisite" #: home/templates/docs/doc_database.html:363 #: home/templates/docs/doc_database.html:366 #: home/templates/docs/doc_database.html:367 -#: scanner/templates/scanner/base.html:132 scanner/views.py:131 +#: scanner/templates/scanner/base.html:132 scanner/views.py:133 msgid "Calibration" msgstr "Calibration" @@ -409,8 +409,7 @@ msgid "Tester le circuit" msgstr "Test circuit" #: home/templates/docs/doc_calibration.html:420 -msgid "" -"Vérifier les déplacements en parcourant automatiquement tous les puits." +msgid "Vérifier les déplacements en parcourant automatiquement tous les puits." msgstr "Check movements by automatically browsing all wells." #: home/templates/docs/doc_calibration.html:431 @@ -803,8 +802,8 @@ msgid "" "Sessions, expériences, configurations et import CSV — interface " "Administration Django" msgstr "" -"Sessions, experiments, configurations and import CSV — Django Administration" -" interface" +"Sessions, experiments, configurations and import CSV — Django Administration " +"interface" #: home/templates/docs/doc_experiment.html:32 #: home/templates/docs/doc_experiment.html:86 @@ -847,8 +846,7 @@ msgstr "CSV import" #: home/templates/docs/doc_experiment.html:55 msgid "" -"C'est l'interface Administration Django qui est chargée de créer les " -"données." +"C'est l'interface Administration Django qui est chargée de créer les données." msgstr "" "The Django Administration interface is responsible for creating the data." @@ -947,8 +945,8 @@ msgstr "Date of export" #: home/templates/docs/doc_experiment.html:169 msgid "" -"Crée une tâche différée pour exporter automatiquement les images et vidéos à" -" la date choisie." +"Crée une tâche différée pour exporter automatiquement les images et vidéos à " +"la date choisie." msgstr "" "Creates a delayed task to automatically export images and videos to the " "selected date." @@ -996,8 +994,7 @@ msgid "Par défaut, tous les puits sont actifs." msgstr "By default, all wells are active." #: home/templates/docs/doc_experiment.html:255 -msgid "" -"Il est possible d'ignorer le balayage d'un ou plusieurs puits au choix." +msgid "Il est possible d'ignorer le balayage d'un ou plusieurs puits au choix." msgstr "It is possible to ignore the scanning of one or more wells of choice." #: home/templates/docs/doc_experiment.html:265 @@ -1630,7 +1627,7 @@ msgstr "Back home" msgid "Ensemble d'expériences" msgstr "Set of experiments" -#: planarian/templates/planarian/base.html:30 +#: planarian/templates/planarian/base.html:27 msgid "Exporter les metrics de l'expérience" msgstr "Export the metrics of the experience" @@ -2450,6 +2447,14 @@ msgstr "Scanning started..." msgid "Erreur lors du démarrage du balayage..." msgstr "Error starting scanning..." +#: scanner/tasks.py:21 +msgid "Balayage démarré." +msgstr "Scanning started..." + +#: scanner/tasks.py:27 +msgid "Replay démarré." +msgstr "Replay started..." + #: scanner/templates/scanner/base.html:5 msgid "Barre de côté" msgstr "Side bar" @@ -2548,7 +2553,7 @@ msgstr "Creation of experience sessions" msgid "Gestions des expériences" msgstr "Experience management" -#: scanner/templates/scanner/base.html:137 scanner/views.py:121 +#: scanner/templates/scanner/base.html:137 scanner/views.py:123 msgid "Balayage multi-puits" msgstr "Multi-well sweeping" @@ -2560,7 +2565,7 @@ msgstr "Results" msgid "Export CSV des expériences" msgstr "CSV export of experiments" -#: scanner/templates/scanner/base.html:146 scanner/views.py:185 +#: scanner/templates/scanner/base.html:146 scanner/views.py:187 msgid "Gestionnaire d'images" msgstr "Image Manager" @@ -2719,22 +2724,34 @@ msgid "Tester le cirduit" msgstr "Test for circumference" #: scanner/templates/scanner/calibration.html:207 -#: scanner/templates/scanner/scanning.html:58 +#: scanner/templates/scanner/scanning.html:59 msgid "ARRET" msgstr "JUDGMENT" -#: scanner/templates/scanner/images.html:30 -msgid "Exporter l'ensemble des images" -msgstr "Export All Images" +#: scanner/templates/scanner/images.html:27 +msgid "Exporter l'ensemble des images de la session" +msgstr "Export All Images for that session" -#: scanner/templates/scanner/images.html:48 +#: scanner/templates/scanner/images.html:45 msgid "Télécharger" msgstr "Download" -#: scanner/templates/scanner/replay.html:32 +#: scanner/templates/scanner/images.html:65 +msgid "Confirmer l'export des images." +msgstr "Export must be confirmed." + +#: scanner/templates/scanner/images.html:66 +msgid "ATTENTION le processus peut être long!" +msgstr "BEWARE The procedure can be lengthy." + +#: scanner/templates/scanner/replay.html:29 msgid "Exporter l'ensemble des vidéos" msgstr "Export all videos" +#: scanner/templates/scanner/replay.html:78 +msgid "Confirmer l'export des vidéos. ATTENTION le processus peut être long!." +msgstr "Confirm videos exportation. BEWARE the procedure can be lengthy." + #: scanner/templates/scanner/scanning.html:14 msgid "Choix de la session" msgstr "Selection of session" @@ -2756,26 +2773,26 @@ msgstr "scanning" msgid "Simuler" msgstr "Simulate" -#: scanner/views.py:109 +#: scanner/views.py:111 msgid "Documentation" msgstr "Documentation" -#: scanner/views.py:230 +#: scanner/views.py:222 scanner/views.py:273 +msgid "Erreur d'exportation" +msgstr "Export error" + +#: scanner/views.py:246 msgid "Gestionnaire de vidéos" msgstr "Video Manager" -#: scanner/views.py:252 +#: scanner/views.py:268 msgid "Images téléchargées" msgstr "Downloaded images" -#: scanner/views.py:255 +#: scanner/views.py:271 msgid "Vidéos téléchargées" msgstr "Videos downloaded" -#: scanner/views.py:257 -msgid "Erreur d'exportation" -msgstr "Export error" - #: templates/admin/base_site.html:4 msgid "Django site admin" msgstr "Django website admin" diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py index 66f5192..40ae208 100644 --- a/test_tube_scanner/planarian/admin.py +++ b/test_tube_scanner/planarian/admin.py @@ -9,11 +9,11 @@ from .models import ExperimentConfig class ExperimentConfigAdmin(admin.ModelAdmin): """Admin Django pour les configurations d'expérience.""" readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',) - list_display = ("experiment_key", "well", "active", "px_per_mm", "fps", + list_display = ("experiment_key", "well", "active", "planarian_count", "px_per_mm", "fps", "thresh_immobile", "thresh_mobile", "photo_mode", "chemo_strength", "created_at", ) - list_filter = ("experiment_key", "photo_mode", "tube_axis") + list_filter = ("experiment_key__session_experiments__session", "experiment_key", "photo_mode", "thigmotaxis_wall_dist_mm", "planarian_count") search_fields = ("experiment_key", "well_name", "description") ordering = ("-created_at",) diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html index 604a186..2f58afa 100644 --- a/test_tube_scanner/planarian/templates/planarian/base.html +++ b/test_tube_scanner/planarian/templates/planarian/base.html @@ -17,10 +17,7 @@ {% endfor %}
- {% for ss in experiments %} - - - {% endfor %} + {% include "scanner/experiment-inc.html" %}
{% block export_csv %} diff --git a/test_tube_scanner/scanner/export_tasks.py b/test_tube_scanner/scanner/export_tasks.py index 734d02d..5e55cbf 100644 --- a/test_tube_scanner/scanner/export_tasks.py +++ b/test_tube_scanner/scanner/export_tasks.py @@ -7,7 +7,7 @@ import posix_ipc import mmap import cv2 import numpy as np -from django.http import JsonResponse, HttpResponse +#from django.http import JsonResponse, HttpResponse from django.conf import settings from reduct.time import unix_timestamp_to_iso @@ -38,15 +38,10 @@ async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None): async def remove_video(uuid, start_ts, end_ts, when=None): - try: - await remove_video_by_uuid(uuid, start_ts, end_ts, when=when) - return JsonResponse({'state': 'ok'}, status=200) - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - + await remove_video_by_uuid(uuid, start_ts, end_ts, when=when) async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): - try: + try: record_manager = CameraRecordManager(cameraDB) total_size = await record_manager.size(uuid, start_ts, end_ts) @@ -79,7 +74,8 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc total += 1 if not frame_sizes: - return JsonResponse({'error': 'Aucune frame trouvée'}, status=404) + raise Exception("No frame found!") + #return JsonResponse({'error': 'Aucune frame trouvée'}, status=404) video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}") fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format) @@ -97,36 +93,16 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc video.write(frame) current_offset += size - progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) - i+=1 - + progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30) + i+=1 video.release() # Nettoie la mémoire partagée shm.unlink() - - # Vérifier que le fichier existe - if not os.path.exists(video_path): - logger.error(f"Fichier non créé: {video_path}") - return JsonResponse({'error': 'Erreur création vidéo'}, status=500) - - # Lit le fichier vidéo généré - with open(video_path, 'rb') as f: - video_bytes = f.read() - - # Retourne la vidéo en réponse - response = HttpResponse(video_bytes, content_type='video/mp4') - response['Content-Disposition'] = f'attachment; filename="{video_path}"' - response['Content-Length'] = os.path.getsize(video_path) - - - # Supprime le fichier temporaire - os.remove(video_path) - return response - + + return { "status": 404, "success": True, "video_path": video_path} except Exception as e: - logger.error(f"shm_download_video: {e}") - return JsonResponse({'error': str(e)}, status=500) + return {"status": 500, "success": False, "video_path": video_path, "error": str(e)} # ───────────────────────────────────────────── ## @@ -247,7 +223,7 @@ async def export_images_zip( total += 1 if not frame_sizes: - return {"status": "error", "message": "Aucune frame trouvée"} + return {"status": "error", "message": "No frame found!..."} # --- Génération du ZIP --- max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0 @@ -297,7 +273,7 @@ async def export_images_zip( zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes()) written += 1 - progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) + progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30) i+=1 current_offset += size @@ -402,7 +378,7 @@ async def export_video_mp4( total +=1 if not frame_sizes: - return {"status": "error", "message": "Aucune frame trouvée"} + return {"status": "error", "message": "No frame found!..."} # --- Génération du MP4 --- @@ -458,14 +434,14 @@ async def export_video_mp4( written += 1 current_offset += size - progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) + progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30) i+=1 if video: video.release() if not os.path.exists(video_path): - return {"status": "error", "message": f"Fichier {opencv_video_type} non créé"} + return {"status": "error", "message": f"File {opencv_video_type} not created!..."} ## Copie vers les destinations (local + Samba) filename = os.path.basename(video_path) diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index ce64e75..a83752c 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -42,7 +42,7 @@ TUBE_AXIS_TYPE = [ class Configuration(models.Model): name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut")) - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) # Dashboard configuration sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px") default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3) @@ -94,7 +94,7 @@ class Configuration(models.Model): return f'{self.name}' class Well(models.Model): - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) name = models.CharField(_("Nom"), help_text=_("Nom du puit: Ai..Di"), unique=True, max_length=4, null=True, blank=True) class Meta: @@ -110,7 +110,7 @@ class Well(models.Model): class MultiWell(models.Model): # Identification label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True) - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False) default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False) # Configuration @@ -175,7 +175,7 @@ class MultiWell(models.Model): class WellPosition(models.Model): - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) @@ -239,7 +239,7 @@ class Experiment(models.Model): comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True) identifier = models.CharField(_("Identifiant d'expérience"), unique=True, max_length=100, null=True, blank=False ) - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de la prise de vue en secondes'), blank=False, default=120) @@ -249,7 +249,7 @@ class Experiment(models.Model): def save(self, *args, **kwargs): - self.identifier = f'{self.id}-{self.multiwell.position}_{self.created.isoformat()[:19]}' + self.identifier = f'{self.multiwell.position}_{self.created.isoformat()[:19]}' super().save(*args, **kwargs) @@ -264,7 +264,7 @@ class Experiment(models.Model): verbose_name_plural = _("Expériences") def __str__(self): - return f'{self.identifier}' + return f'{self.identifier} [ {self.title} ]' class Session(models.Model): @@ -276,7 +276,7 @@ class Session(models.Model): ERROR = "error", _("Erreur") name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) active = models.BooleanField(_("Active"), default=True) expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True) expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True) @@ -313,7 +313,7 @@ class Session(models.Model): def __str__(self): state = _("Terminée") if not self.active else _("Active") - return f'{self.id}: {self.name} {state}' + return f'[ {self.id} ] {self.name} ({state})' @receiver(post_save, sender=Session) @@ -376,9 +376,9 @@ def delete_periodic_task(sender, instance, **kwargs): class SessionExperiment(models.Model): - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) - session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True) - experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments") + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) + session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.CASCADE, null=True, blank=True) + experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="session_experiments") @classmethod def experiment_by_session(cls, session_id, active=True): @@ -407,8 +407,8 @@ class SessionExperiment(models.Model): class ExperimentWell(models.Model): - author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) - experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="experimentwell") + author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) + experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="experimentwell") well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True, related_name="wellexperiment") active = models.BooleanField(_("Active"), default=True) diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index 683b049..b45b71d 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -151,7 +151,7 @@ class MultiWellManager: multiwell = experiment.multiwell if self.process.use_tracking: - cfg = ExperimentConfig.objects.filter(experiment_id=experiment.id, well_id=well.id).first() + cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).first() if not cfg: raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}") # reset PlanarianTracker => on_well_change @@ -159,14 +159,16 @@ class MultiWellManager: ## create uuid for this capture uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}' - + ## start recording self.process.data.uuid = uuid if not simulate: self.process.data.record = True self.process._send(current=well_position.order) - logger.info(f"Starting capture for {uuid} ordre: {well_position.order}") + msg = f"Starting capture for {uuid} ordre: {well_position.order}" + logger.info(msg) + self.process._send(well_state=msg) start = time.monotonic() while not self.stop_playing.is_set(): @@ -249,7 +251,9 @@ class MultiWellManager: started = timezone.now() for obs in experiments: - logger.warning(f"Starting scan for {obs} (well {pos}/{len(experiments)})") + msg = f"Starting scan for {obs} (well {pos}/{len(experiments)})" + logger.warning(msg) + self.process._send(well_state=msg) if self.stop_playing.is_set(): break diff --git a/test_tube_scanner/scanner/static/scanner/js/scanning.js b/test_tube_scanner/scanner/static/scanner/js/scanning.js index f3941ac..59405f2 100644 --- a/test_tube_scanner/scanner/static/scanner/js/scanning.js +++ b/test_tube_scanner/scanner/static/scanner/js/scanning.js @@ -25,6 +25,7 @@ class ScannerManager { this.scan_state = options.scan_state; this.sim_bt = options.simulate || null; this.well_btn = options.well_btn; + this.well_state = options.well_state; } init_controls() { @@ -56,7 +57,8 @@ class ScannerManager { } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;} - + if (payload.well_state) { this.well_state.textContent=payload.well_state;} + if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f'; @@ -66,9 +68,7 @@ class ScannerManager { if (btn.value==payload.current) { btn.classList.add('w3-green'); return; } btn.classList.remove('w3-green'); }); - } - - + } } catch(e) { console.log(e); } } diff --git a/test_tube_scanner/scanner/tasks.py b/test_tube_scanner/scanner/tasks.py index 9b87619..f4aa13f 100644 --- a/test_tube_scanner/scanner/tasks.py +++ b/test_tube_scanner/scanner/tasks.py @@ -5,7 +5,7 @@ import requests from celery import shared_task, group, chord, chain from celery.utils.log import get_task_logger from django.utils import timezone - +from django.utils.translation import gettext_lazy as _ from .process import ScannerProcess, ReplayProcess, redisDB from .export_tasks import shm_download_video, export_images_zip, export_video_mp4 from .constants import ScannerConstants @@ -18,21 +18,35 @@ logger = get_task_logger(__name__) def scanner_start(): scanner = ScannerProcess() scanner.start() - return f"Scanner démarré." + return str(_("Scanner démarré.")) @shared_task def replay_start(): replay = ReplayProcess(latency=5.0) replay.start() - return f"Replay démarré." + return str(_("Replay démarré.")) -@shared_task -def download_video(uuid, start_ts, end_ts, frame_rate=10, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): - try: - return asyncio.run(shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type)) + +@shared_task(bind=True) +def download_video(self, uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): + """ + Exécuter directement la fonction async DANS la tâche Celery. + Sans passer par une wrapper. + """ + try: + logger.info(f"Task {self.request.id}: Processing {uuid} from {start_ts} to {end_ts}") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete( + shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type) ) + return result + finally: + loop.close() except Exception as e: - logger.error(f"download_video: {e}") - + logger.error(f"shm_download_video_task error: {e}", exc_info=True) + return { 'status': 'error', 'error': str(e), 'uuid': uuid} + @shared_task def export_images( uuid: str, @@ -46,7 +60,8 @@ def export_images( return asyncio.run(export_images_zip(uuid, start_ts, end_ts, jpeg_quality, max_zip_size_mb, max_image_width, max_image_height)) except Exception as e: logger.error(f"export_images: {e}") - + + @shared_task def export_all_images(session_id=None): try: @@ -72,13 +87,11 @@ def export_all_images(session_id=None): return job_zip except Exception as e: - logger.error(f"export_images: {e}") + logger.error(f"export_images: {e}") + @shared_task -def export_videos(uuid: str, - start_ts: float | None = None, - end_ts: float | None = None, - frame_rate: int = 5, +def export_videos(uuid: str, start_ts: float | None = None, end_ts: float | None = None, frame_rate: int = 5, opencv_fourcc_format='mp4v', opencv_video_type='mp4', max_video_size_mb: int = 0, @@ -88,6 +101,7 @@ def export_videos(uuid: str, return asyncio.run(export_video_mp4(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type, max_video_size_mb, max_width, max_height)) except Exception as e: logger.error(f"export_videos: {e}") + return {"success": False, "error": str(e)} @shared_task diff --git a/test_tube_scanner/scanner/templates/scanner/experiment-inc.html b/test_tube_scanner/scanner/templates/scanner/experiment-inc.html new file mode 100644 index 0000000..58d897f --- /dev/null +++ b/test_tube_scanner/scanner/templates/scanner/experiment-inc.html @@ -0,0 +1,6 @@ +{% for ss in experiments %} +
+ + +
+{% endfor %} \ No newline at end of file diff --git a/test_tube_scanner/scanner/templates/scanner/images.html b/test_tube_scanner/scanner/templates/scanner/images.html index a4b88e1..6ca5244 100644 --- a/test_tube_scanner/scanner/templates/scanner/images.html +++ b/test_tube_scanner/scanner/templates/scanner/images.html @@ -16,18 +16,15 @@ {% endfor %}
- {% for ss in experiments %} - - - {% endfor %} + {% include "scanner/experiment-inc.html" %}
{% if current_session.id and current_experiment %} {% multiwell_cards current_session.id current_experiment %} {% endif %} {% if current_session.id %} - - {% trans "Exporter l'ensemble des images" %}
+
+ {% trans "Exporter l'ensemble des images de la session" %}
{{ export_destination }}
{% endif %} @@ -65,7 +62,8 @@ const duration = parseInt("{{ duration }}"); const uuid_btn = sId("btn-" + "{{ uuid }}"); const image_endpoint = "{% url 'scanner:export_api' %}"; - const sid = parseInt("{{ cursid }}"); + const export_msg = "{% trans "Confirmer l'export des images." %}"; + const export_msg_long = "{% trans "ATTENTION le processus peut être long!" %}"; function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); } function handleMultiwell(b) { b.classList.add('w3-green'); } @@ -78,7 +76,7 @@ } function image_save(name, img) { - const ok = confirm(`Télécharger le fichier ?\n\n${name}`); + const ok = confirm(export_msg); if (!ok) return false; fetch(img) .then(res => res.blob()) @@ -94,7 +92,9 @@ }); } - function download_all_images() { + function download_all_images(sid) { + const ok = confirm(export_msg +' '+ export_msg_long); + if (!ok) return false; fetch(image_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -109,7 +109,8 @@ }) .catch(error => { console.error('Erreur:', error); - }); + }); + } setGridColumns(columns); if (uuid_btn) diff --git a/test_tube_scanner/scanner/templates/scanner/replay.html b/test_tube_scanner/scanner/templates/scanner/replay.html index 4ec5123..784fb07 100644 --- a/test_tube_scanner/scanner/templates/scanner/replay.html +++ b/test_tube_scanner/scanner/templates/scanner/replay.html @@ -17,10 +17,7 @@ {% endfor %}
- {% for ss in experiments %} - - - {% endfor %} + {% include "scanner/experiment-inc.html" %}
{% if current_session.id and current_experiment %} @@ -28,7 +25,7 @@ {% endif %} {% if current_session.id %} - + {% trans "Exporter l'ensemble des vidéos" %}
{{ export_destination }}
@@ -78,9 +75,12 @@ const ws_route = "{{ ws_route }}"; const uuid_btn = sId("btn-" + "{{ uuid }}"); const image_endpoint = "{% url 'scanner:export_api' %}"; - const sid = parseInt("{{ cursid }}"); + const export_msg = "{% trans "Confirmer l'export des vidéos. ATTENTION le processus peut être long!" %}"; - function download_all_videos() { + + function download_all_videos(sid) { + const ok = confirm(export_msg); + if (!ok) return false; fetch(image_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/test_tube_scanner/scanner/templates/scanner/scanning.html b/test_tube_scanner/scanner/templates/scanner/scanning.html index 0bdd1a5..a45c4fe 100644 --- a/test_tube_scanner/scanner/templates/scanner/scanning.html +++ b/test_tube_scanner/scanner/templates/scanner/scanning.html @@ -51,7 +51,8 @@ {% endif %}
-
+
+
@@ -100,7 +101,9 @@ frame_count : sId("_count"), scan_state : sId("_scan_state"), simulate : sId("_simulate"), - well_btn : sId("_well_btn") + well_btn : sId("_well_btn"), + well_state : sId("_well_state") + }; diff --git a/test_tube_scanner/scanner/views.py b/test_tube_scanner/scanner/views.py index fa9f15c..a6bb798 100644 --- a/test_tube_scanner/scanner/views.py +++ b/test_tube_scanner/scanner/views.py @@ -1,13 +1,15 @@ # +import os from asgiref.sync import async_to_sync import base64, json from django.shortcuts import render #, redirect -from django.http import JsonResponse +from django.http import JsonResponse, FileResponse from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET, require_POST from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required, user_passes_test from django.conf import settings + from reduct.time import unix_timestamp_to_iso from modules.system_stats import get_cached_stats, start_background_updater from modules import reductstore @@ -198,13 +200,28 @@ def images_view(request): @csrf_exempt def download_api(request): data = json.loads(request.body.decode() or "{}") - action = data.get("action") - if action=='download': - uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps") - return download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable - else: - return JsonResponse({"state": False}) + try: + action = data.get("action") + if action=='download': + uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps") + task = download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable + result = task.get(timeout=300) + + if result.get('success'): + video_path = result.get('video_path') + if not os.path.exists(video_path): + raise Exception(f"File not created: {video_path}") + response = FileResponse(open(video_path, 'rb'), content_type='video/mp4') + response['Content-Disposition'] = f'attachment; filename="{video_path}"' + response['Content-Length'] = os.path.getsize(video_path) + os.remove(video_path) + return response + return JsonResponse(result) + except Exception as e: + print("download_api error", e) + return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), }) + def get_video(uuid): oldest, latest = async_to_sync(reductstore.old_last_dates)(cameraDB, entry_name=uuid) @@ -246,14 +263,13 @@ def export_api(request): data = json.loads(request.body.decode() or "{}") session_id = data.get("sid") action = data.get("action") - - if action == 'export_images': - export_all_images(session_id) - return JsonResponse({"success": True, "msg": str(_("Images téléchargées"))}) - elif action == 'export_videos': - export_all_videos(session_id) - return JsonResponse({"success": True, "msg": str(_("Vidéos téléchargées"))}) - else: - return JsonResponse({"success": False, "msg": str(_("Erreur d'exportation"))}) - + try: + if action == 'export_images': + export_all_images(session_id) + return JsonResponse({"success": True, "msg": str(_("Images téléchargées"))}) + elif action == 'export_videos': + export_all_videos(session_id) + return JsonResponse({"success": True, "msg": str(_("Vidéos téléchargées"))}) + except: + return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), })