From 01acef913bcf0f3c70f4722f1445d73acffbc4d5 Mon Sep 17 00:00:00 2001 From: denis defolie Date: Wed, 13 May 2026 13:21:49 +0200 Subject: [PATCH] export/import metrics --- .../home/templates/inc/alert.html | 12 +- .../modules/planarian_metrics.py | 2 +- test_tube_scanner/planarian/admin.py | 19 +- test_tube_scanner/planarian/forms.py | 104 ---- test_tube_scanner/planarian/models.py | 36 +- test_tube_scanner/planarian/tasks.py | 54 ++ .../planarian/templates/planarian/base.html | 33 +- .../templates/planarian/experiment_form.html | 540 ------------------ .../templates/planarian/experiment_list.html | 312 ---------- .../templates/planarian/export_csv.html | 16 +- .../templates/planarian/export_csv2.html | 233 -------- .../templates/planarian/import_params.html | 94 +-- test_tube_scanner/planarian/urls.py | 26 +- test_tube_scanner/planarian/views.py | 209 +++---- test_tube_scanner/scanner/models.py | 9 +- test_tube_scanner/scanner/tasks.py | 1 + .../scanner/templates/scanner/base.html | 19 +- 17 files changed, 293 insertions(+), 1426 deletions(-) delete mode 100644 test_tube_scanner/planarian/forms.py create mode 100644 test_tube_scanner/planarian/tasks.py delete mode 100644 test_tube_scanner/planarian/templates/planarian/experiment_form.html delete mode 100644 test_tube_scanner/planarian/templates/planarian/experiment_list.html delete mode 100644 test_tube_scanner/planarian/templates/planarian/export_csv2.html diff --git a/test_tube_scanner/home/templates/inc/alert.html b/test_tube_scanner/home/templates/inc/alert.html index f7dfded..e870657 100644 --- a/test_tube_scanner/home/templates/inc/alert.html +++ b/test_tube_scanner/home/templates/inc/alert.html @@ -2,10 +2,14 @@ {% if messages %}
{% for message in messages %} -
-
{{ message }}
-
×
-
+
+
{{ message }}
+
×
+
{% endfor %}
{% endif %} diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py index eaf9812..49c1e2c 100644 --- a/test_tube_scanner/modules/planarian_metrics.py +++ b/test_tube_scanner/modules/planarian_metrics.py @@ -618,7 +618,7 @@ class ExperimentParams: def from_csv_file(cls, filepath: str) -> list: """Charge toutes les expériences d'un fichier CSV.""" results = [] - with open(filepath, newline="", encoding="utf-8") as f: + with open(filepath, newline="", encoding="utf-8-sig") as f: for row in csv.DictReader(f): try: results.append(cls.from_csv_row(row)) diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py index e283883..66f5192 100644 --- a/test_tube_scanner/planarian/admin.py +++ b/test_tube_scanner/planarian/admin.py @@ -8,19 +8,18 @@ from .models import ExperimentConfig @admin.register(ExperimentConfig) class ExperimentConfigAdmin(admin.ModelAdmin): """Admin Django pour les configurations d'expérience.""" - #readonly_fields = ('experiment', ) - readonly_fields = ('px_per_mm', 'fps', 'well_radius_mm',) - list_display = ("experiment", "well", "active", "px_per_mm", "fps", + readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',) + list_display = ("experiment_key", "well", "active", "px_per_mm", "fps", "thresh_immobile", "thresh_mobile", "photo_mode", "chemo_strength", "created_at", ) - list_filter = ("experiment", "photo_mode", "tube_axis") - search_fields = ("experiment", "well", "description") + list_filter = ("experiment_key", "photo_mode", "tube_axis") + search_fields = ("experiment_key", "well_name", "description") ordering = ("-created_at",) fieldsets = ( (_("Identification"), { - "fields": ("experiment", "well", "active", "description"), + "fields": ("experiment_key", "well", "active", "description"), }), (_("Calibration optique: générée lors de la calibration"), { "fields": ("px_per_mm", "fps", "well_radius_mm"), @@ -58,19 +57,19 @@ class ExperimentConfigAdmin(admin.ModelAdmin): @admin.action(description=_("Exporter un template CSV de ces configurations")) def export_csv_template(self, request, queryset): import csv - from django.http import HttpResponse + from django.http import FileResponse from io import StringIO output = StringIO() fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable + writer = csv.DictWriter(output, fieldnames=fields) writer.writeheader() for obj in queryset: row = {f: getattr(obj, f) for f in fields} writer.writerow(row) - response = HttpResponse(output.getvalue(), content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"' - + response = FileResponse(output.getvalue(), content_type='text/csv') + response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"' return response diff --git a/test_tube_scanner/planarian/forms.py b/test_tube_scanner/planarian/forms.py deleted file mode 100644 index 556b828..0000000 --- a/test_tube_scanner/planarian/forms.py +++ /dev/null @@ -1,104 +0,0 @@ -# planarian/forms.py - -import csv -import io - -from django import forms -from django.utils.translation import gettext_lazy as _ -from scanner import models -from .models import ExperimentConfig - - - -class ExperimentConfigForm(forms.ModelForm): - """Formulaire de saisie/modification d'un ExperimentConfig.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - #self.fields['identifier'].disabled = True - - class Meta: - model = ExperimentConfig - fields = "__all__" - widgets = { - "description": forms.Textarea(attrs={"rows": 3}), - } - - def clean(self): - cleaned = super().clean() - if cleaned.get("thresh_immobile", 0) >= cleaned.get("thresh_mobile", 1): - raise forms.ValidationError( - _("Le seuil Immobile doit être inférieur au seuil Mobile.") - ) - if cleaned.get("avoid_radius_mm", 0) >= cleaned.get("aggreg_radius_mm", 1): - raise forms.ValidationError( - _("Le rayon d'évitement doit être inférieur au rayon d'agrégation.") - ) - return cleaned - - -class CsvImportForm(forms.Form): - """Formulaire d'import de paramètres depuis un fichier CSV.""" - - csv_file = forms.FileField( - label=_("Fichier CSV"), - help_text=_( - "Colonnes obligatoires : experiment, well, px_per_mm, fps. " - "Toutes les autres colonnes sont optionnelles." - ), - ) - overwrite = forms.BooleanField( - required=False, - initial=False, - label=_("Écraser les configurations existantes"), - ) - - - def clean_csv_file(self): - f = self.cleaned_data["csv_file"] - try: - content = f.read().decode("utf-8") - reader = csv.DictReader(io.StringIO(content)) - rows = list(reader) - except Exception as e: - raise forms.ValidationError(_("Fichier CSV invalide : %(err)s") % {"err": e}) - - required = {"experiment", "well", "px_per_mm", "fps"} - if rows: - missing = required - set(rows[0].keys()) - if missing: - raise forms.ValidationError( - _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)} - ) - self.csv_rows = rows - return f - - -class ExportCsvForm(forms.Form): - """Formulaire de demande d'export CSV depuis ReductStore.""" - - experiment = forms.CharField(label=_("Expérience"), max_length=100) - #well = forms.CharField(label=_("Puits"), max_length=20) - well = forms.ModelChoiceField( - queryset=models.Well.objects.filter().order_by('name').all(), - label=_('Puits'), - widget=forms.Select(attrs={'class': 'w3-select',}), - required=True,initial="D1", - ) - planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0, max_value=20) - record_type = forms.ChoiceField( - label=_("Type d'enregistrement"), - choices=[("frame", _("Frame par frame")), ("summary", _("Résumé"))], - initial="frame", - ) - start_dt = forms.DateTimeField( - label=_("Début (UTC)"), - required=False, - widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), - ) - stop_dt = forms.DateTimeField( - label=_("Fin (UTC)"), - required=False, - widget=forms.DateTimeInput(attrs={"type": "datetime-local"}), - ) - diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py index 6376a92..7fa58e1 100644 --- a/test_tube_scanner/planarian/models.py +++ b/test_tube_scanner/planarian/models.py @@ -6,6 +6,12 @@ from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from scanner.models import Experiment, Well +WELL_CHOICES = [] +def get_well_choices(): + wells = Well.objects.order_by('name').all() + for well in wells: + WELL_CHOICES.append((well.name, well.name)) + class ExperimentConfig(models.Model): """ @@ -13,12 +19,14 @@ class ExperimentConfig(models.Model): Peut être créé depuis Django admin, une vue formulaire ou un import CSV. """ 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.CASCADE, related_name="experiment_well", null=True, blank=False) - well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", null=True, blank=False ) + experiment_key = models.ForeignKey(Experiment, verbose_name="Expérience", on_delete=models.CASCADE, null=True, blank=False) + experiment = models.CharField(_("Identifiant expérience"), max_length=128, null=True, blank=False, default='Identifier' ) + well = models.CharField(_("Puit"), help_text=_("Nom du puit"), max_length=8, choices=WELL_CHOICES, null=True, blank=False, default='A1' ) description = models.TextField( blank=True, verbose_name=_("Description"), default="-") - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) active = models.BooleanField(_("Active"), default=True) # --- Calibration optique --- @@ -117,24 +125,32 @@ class ExperimentConfig(models.Model): avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (mm)")) aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)")) + class Meta: verbose_name = _("Configuration d'une expérience") verbose_name_plural = _("Configurations des expériences") - unique_together = ("experiment", "well") - ordering = ["-created_at"] + unique_together = ("experiment_key", "well") + ordering = ["-created_at"] def __str__(self): - return f"{self.experiment}:{self.well.name}" + return f"{self.experiment_key}:{self.well}" + + def save(self, *args, **kwargs): + if not self.author: + self.author = self.experiment_key.author + self.experiment = self.experiment_key.identifier + super().save(*args, **kwargs) + def get_session(self): - return self.experiment.session_experiments.first() if self.experiment else None + return self.experiment_key.session_experiments.first() if self.experiment_key else None + def to_params_dict(self) -> dict: """Retourne un dict compatible avec ExperimentParams.""" return { - "experiment": self.experiment.identifier, - "well": self.well.name, - + "experiment": self.experiment, + "well": self.well, "px_per_mm": self.px_per_mm, "fps": self.fps, "well_radius_mm": self.well_radius_mm, diff --git a/test_tube_scanner/planarian/tasks.py b/test_tube_scanner/planarian/tasks.py new file mode 100644 index 0000000..90edbf9 --- /dev/null +++ b/test_tube_scanner/planarian/tasks.py @@ -0,0 +1,54 @@ +# tasks.py +from celery import shared_task +from celery.utils.log import get_task_logger + +from django.conf import settings +from asgiref.sync import async_to_sync +from modules.planarian_metrics import ReductStoreClient +from scanner.models import SessionExperiment +from .models import ExperimentConfig + + +logger = get_task_logger(__name__) + +def _get_reduct_client() -> ReductStoreClient: + """Instancie le client ReductStore depuis les settings Django.""" + return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN) + + +@shared_task +def export_experiment_metrics(experiment): + + @async_to_sync + async def _do_export(well, planarian, record_type): + client = _get_reduct_client() + + await client.connect() + try: + f, n = await client.export_csv( + experiment = experiment.identifier, + well = well, + planarian = planarian, + record_type = record_type, + output_dir = settings.CSV_EXPORT_DIR, + ) + logger.warning(f"Export CSV {settings.CSV_EXPORT_DIR}/{f} done, {n} lignes") + except Exception as e: + logger.error(f"Erreur export CSV: {e}") + + experiment_configs = ExperimentConfig.objects.filter(experiment_key_id=experiment.id).order_by('well').all() + for conf in experiment_configs: + well = conf.well + count = conf.planarian_count + + for record_type in ["frame", "summary"]: + for planarian in range(count): + _do_export(well, planarian, record_type) + +@shared_task +def export_session_metrics(session): + experiments = SessionExperiment.experiment_by_session(session.id) + for experiment in experiments: + export_experiment_metrics(experiment) + + \ No newline at end of file diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html index c299f26..dfaa35f 100644 --- a/test_tube_scanner/planarian/templates/planarian/base.html +++ b/test_tube_scanner/planarian/templates/planarian/base.html @@ -23,15 +23,15 @@ {% endfor %} - {% if current_session.id and current_experiment.id %} -
-
- {% csrf_token %} - - - -
- {% endif %} + {% if current_session and current_experiment %} + {% url "planarian:api-export-csv" as url_export %} + + {% trans "Exporter les metrics de l'expérience" %}
+  $> {{ export_csv_destination }} +
+ + {% endif %} + {% endblock %} {% block js_footer %} @@ -53,5 +53,20 @@ }, 15000); // 15 seconds }); }); + function export_csv(url, pid, mode) { + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: mode, + pid: pid, + }) + }) + .then(response => response.json()) + .then(res => { alert(res.msg); }) + .catch(error => { console.error('Erreur:', error); }); + } + + {% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_form.html b/test_tube_scanner/planarian/templates/planarian/experiment_form.html deleted file mode 100644 index 1e1a850..0000000 --- a/test_tube_scanner/planarian/templates/planarian/experiment_form.html +++ /dev/null @@ -1,540 +0,0 @@ -{% extends "planarian/base.html" %} -{% load i18n %} - -{% block content %} - -
- - -
-
- - 🔬 - {% if object %} - {% trans "Modifier la configuration" %} — {{ object }} - {% else %} - {% trans "Nouvelle configuration d'expérience" %} - {% endif %} - -
-
- - - {% for message in messages %} -
-

{{ message }}

-
- {% endfor %} - -
- {% csrf_token %} - - - - - - {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -

⚠ {{ error }}

- {% endfor %} -
- {% endif %} - - -
-
-

{% trans "Identification" %}

-
-
-
- - -
- - {{ form.author }} - {% if form.author.errors %} - {{ form.author.errors|join:", " }} - {% endif %} -
- - -
- - {{ form.experiment }} - {% if form.experiment.errors %} - {{ form.experiment.errors|join:", " }} - {% endif %} -
- - -
- - {{ form.well }} - {% if form.well.errors %} - {{ form.well.errors|join:", " }} - {% endif %} -
- -
- - -
- - {{ form.description }} - {% if form.description.errors %} - {{ form.description.errors|join:", " }} - {% endif %} -
- -
-
- - -
-
-

{% trans "Calibration optique" %}

-
-
-
- -
- - {% if form.px_per_mm.help_text %} -

{{ form.px_per_mm.help_text }}

- {% endif %} - {{ form.px_per_mm }} - {% if form.px_per_mm.errors %} - {{ form.px_per_mm.errors|join:", " }} - {% endif %} -
- -
- - {% if form.fps.help_text %} -

{{ form.fps.help_text }}

- {% endif %} - {{ form.fps }} - {% if form.fps.errors %} - {{ form.fps.errors|join:", " }} - {% endif %} -
- -
- - {% if form.well_radius_mm.help_text %} -

{{ form.well_radius_mm.help_text }}

- {% endif %} - {{ form.well_radius_mm }} - {% if form.well_radius_mm.errors %} - {{ form.well_radius_mm.errors|join:", " }} - {% endif %} -
- -
-
-
- -
-
-

{% trans "Tracker" %}

-
-
-
- -
- - {{ form.tube_axis }} - {% if form.tube_axis.errors %} - {{ form.tube_axis.errors|join:", " }} - {% endif %} -
- -
- - {{ form.min_area_px }} - {% if form.min_area_px.errors %} - {{ form.min_area_px.errors|join:", " }} - {% endif %} -
- -
- - {{ form.max_area_ratio }} - {% if form.max_area_ratio.errors %} - {{ form.max_area_ratio.errors|join:", " }} - {% endif %} -
- -
- - {{ form.planarian_count }} - {% if form.planarian_count.errors %} - {{ form.planarian_count.errors|join:", " }} - {% endif %} -
- -
- - {{ form.min_contour_dist_px }} - {% if form.min_contour_dist_px.errors %} - {{ form.min_contour_dist_px.errors|join:", " }} - {% endif %} -
- -
- - {{ form.merge_kernel_size }} - {% if form.merge_kernel_size.errors %} - {{ form.merge_kernel_size.errors|join:", " }} - {% endif %} -
-
-
-
- -
-
-

{% trans "Seuils de mobilité EthoVision XT" %}

-
-
- - -
-
- {% trans "Immobile" %} -
-
- {% trans "Mobile" %} -
-
- {% trans "Très mobile" %} -
-
-

- {% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %} -

- -
-
- -

{% trans "En-dessous : Immobile (mm/s)" %}

- {{ form.thresh_immobile }} - {% if form.thresh_immobile.errors %} - {{ form.thresh_immobile.errors|join:", " }} - {% endif %} -
-
- -

{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}

- {{ form.thresh_mobile }} - {% if form.thresh_mobile.errors %} - {{ form.thresh_mobile.errors|join:", " }} - {% endif %} -
-
-
-
- - -
-
-

{% trans "Comportements" %}

-
- - -
- -
-
-
- -

- {% trans "Distance à la paroi considérée « près du bord » (mm)" %} -

- {{ form.thigmotaxis_wall_dist_mm }} - {% if form.thigmotaxis_wall_dist_mm.errors %} - {{ form.thigmotaxis_wall_dist_mm.errors|join:", " }} - {% endif %} -
-
-
-
- - -
- -
-
- -
- -

 

- {{ form.photo_mode }} - {% if form.photo_mode.errors %} - {{ form.photo_mode.errors|join:", " }} - {% endif %} -
- -
- -

{% trans "0.0 = désactivé → 1.0 = fort" %}

- {{ form.photo_strength }} - {% if form.photo_strength.errors %} - {{ form.photo_strength.errors|join:", " }} - {% endif %} -
- -
- -

 

- {{ form.photo_x }} - {% if form.photo_x.errors %} - {{ form.photo_x.errors|join:", " }} - {% endif %} -
- -
- -

 

- {{ form.photo_y }} - {% if form.photo_y.errors %} - {{ form.photo_y.errors|join:", " }} - {% endif %} -
- -
-
-
- - -
- -
-
- -
- -

{% trans "0.0 = désactivé → 1.0 = fort" %}

- {{ form.chemo_strength }} - {% if form.chemo_strength.errors %} - {{ form.chemo_strength.errors|join:", " }} - {% endif %} -
- -
- -

{% trans "Mini = 0, maxi = 1" %}

- {{ form.chemo_x }} - {% if form.chemo_x.errors %} - {{ form.chemo_x.errors|join:", " }} - {% endif %} -
- -
- -

{% trans "Mini = 0, maxi = 1" %}

- {{ form.chemo_y }} - {% if form.chemo_y.errors %} - {{ form.chemo_y.errors|join:", " }} - {% endif %} -
- -
- - {{ form.chemo_radius_mm }} - {% if form.chemo_radius_mm.errors %} - {{ form.chemo_radius_mm.errors|join:", " }} - {% endif %} -
- -
-
-
- - -
- -
-
- -
- -

{% trans "Rayon de répulsion courte portée (mm)" %}

- {{ form.avoid_radius_mm }} - {% if form.avoid_radius_mm.errors %} - {{ form.avoid_radius_mm.errors|join:", " }} - {% endif %} -
- -
- -

{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}

- {{ form.aggreg_radius_mm }} - {% if form.aggreg_radius_mm.errors %} - {{ form.aggreg_radius_mm.errors|join:", " }} - {% endif %} -
- -
-
-
- -
- - -
- -
- - - ✖ {% trans "Retour" %} - -
- - {% if object %} - - {% endif %} - -
- -
- -
- - - - - - -{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_list.html b/test_tube_scanner/planarian/templates/planarian/experiment_list.html deleted file mode 100644 index f848360..0000000 --- a/test_tube_scanner/planarian/templates/planarian/experiment_list.html +++ /dev/null @@ -1,312 +0,0 @@ -{% extends "planarian/base.html" %} -{% load i18n home_tags scanner_tags %} - -{% block content %} -
- -
-
-
- - 🔬 - {% trans "Configurations d'expériences" %} - -
- -
-
- - {% for message in messages %} -
- -

{{ message }}

-
- {% endfor %} - - - - - {% if configs %} - -

- {{ configs|length }} {% trans "configuration(s)" %} -

-
- - - - - - - - - - - - - - - {% for cfg in configs %} - - - - - - - - - - - - - - - - - - - - {% endfor %} - -
- {% trans "Expérience" %} - - {% trans "Puits" %} - - {% trans "px/mm" %} - {% trans "FPS" %}{% trans "Seuils (mm/s)" %}{% trans "Comportements" %} - {% trans "Créé le" %} - {% trans "Actions" %}
- {{ cfg.experiment }} - {% if cfg.description %} -
{{ cfg.description|truncatechars:40 }} - {% endif %} -
- {{ cfg.well }} - {{ cfg.px_per_mm }}{{ cfg.fps }} - - < {{ cfg.thresh_immobile }} - - - < {{ cfg.thresh_mobile }} - - - {% if cfg.thigmotaxis_wall_dist_mm > 0 %} - 🫧 - {% endif %} - {% if cfg.photo_mode != 'none' and cfg.photo_strength > 0 %} - 💡 - {% endif %} - {% if cfg.chemo_strength > 0 %} - 🍖 - {% endif %} - {% if cfg.avoid_radius_mm > 0 or cfg.aggreg_radius_mm > 0 %} - 🔀 - {% endif %} - - {{ cfg.created_at|date:"d/m/Y H:i" }} - - {% if current_session and current_experiment %} - - 📥 - -
-
- - - - {% else %} - -
-

🔬

-

{% trans "Aucune configuration pour l'instant." %}

-

- {% trans "Créez une première configuration ou importez un fichier CSV." %} -

- - ➕ {% trans "Nouvelle configuration" %} - - 📂 {% trans "Importer CSV" %} -
- {% endif %} -
- - -
-
-
-

🗑 {% trans "Confirmer la suppression" %}

-
-
-

-
- {% csrf_token %} - - - -
-
-
-
- - - - - - -{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/export_csv.html b/test_tube_scanner/planarian/templates/planarian/export_csv.html index 517e221..ab84f5f 100644 --- a/test_tube_scanner/planarian/templates/planarian/export_csv.html +++ b/test_tube_scanner/planarian/templates/planarian/export_csv.html @@ -21,7 +21,6 @@ -

{% trans "Paramètres d'export" %}

@@ -161,3 +160,18 @@
{% endif %} {% endblock %} + + + + + + + + + + + + + + + diff --git a/test_tube_scanner/planarian/templates/planarian/export_csv2.html b/test_tube_scanner/planarian/templates/planarian/export_csv2.html deleted file mode 100644 index 6e4ff43..0000000 --- a/test_tube_scanner/planarian/templates/planarian/export_csv2.html +++ /dev/null @@ -1,233 +0,0 @@ -{% extends "planarian/base.html" %} - -{% load i18n %} - -{% block content %} - -
-{% if current_session and current_experiment %} - -
-
-
- - 📥 {% trans "Exporter les données vers CSV" %} - -
- -
-
- - - {% for message in messages %} -
-

{{ message }}

-
- {% endfor %} - - -
-

- {% trans "Sélectionnez l'expérience, le puits et le type d'enregistrement à exporter." %} - {% trans "Le fichier CSV sera généré depuis ReductStore et téléchargé directement." %} -

-

- {% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %} -

-
- - -
- {% csrf_token %} - - - - - {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -

⚠ {{ error }}

- {% endfor %} -
- {% endif %} - -
-
-

{% trans "Paramètres d'export" %}

-
-
- -
- -
- - {% if form.experiment.help_text %} -

{{ form.experiment.help_text }}

- {% endif %} - - {{ form.experiment }} - {% if form.experiment.errors %} - - {% endif %} -
- -
- - {{ form.well }} - {% if form.well.errors %} - {{ form.well.errors|join:", " }} - {% endif %} -
- -
- - -
- -
- -

- {% trans "Index du planaire dans le puits (commence à 0)" %} -

- {{ form.planarian }} - {% if form.planarian.errors %} - {{ form.planarian.errors|join:", " }} - {% endif %} -
- -
- -

- {% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %} -

- -
- {{ form.record_type }} -
- {% if form.record_type.errors %} - {{ form.record_type.errors|join:", " }} - {% endif %} -
- -
- - -
-

- {% trans "Plage temporelle" %} - - {% trans "(optionnel — laisser vide pour exporter toute la session)" %} - -

-
- -
- - {{ form.start_dt }} - {% if form.start_dt.errors %} - {{ form.start_dt.errors|join:", " }} - {% endif %} -
- -
- - {{ form.stop_dt }} - {% if form.stop_dt.errors %} - {{ form.stop_dt.errors|join:", " }} - {% endif %} -
- -
-
- -
-
- - -
-
- - - ✖ {% trans "Annuler" %} - -
-
- -
- - -
-
-

- {% trans "Colonnes du fichier CSV exporté" %} -

-
-
-
- -
-

{% trans "Identification / position" %}

-
    -
  • timestamp
  • -
  • x_mm / y_mm
  • -
  • cx_px / cy_px
  • -
  • area_px
  • -
  • axial_pos / axial_speed
  • -
-
- -
-

{% trans "Métriques EthoVision XT" %}

-
    -
  • velocity_mm_s — {% trans "vitesse instantanée" %}
  • -
  • total_distance_mm — {% trans "distance cumulée" %}
  • -
  • moving / duration_moving_s
  • -
  • mobility_state — {% trans "Immobile / Mobile / Highly mobile" %}
  • -
  • dist_to_wall_mm / near_wall
  • -
-
- -
-
-
- -
- - -{% endif %} -{% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/import_params.html b/test_tube_scanner/planarian/templates/planarian/import_params.html index 98a21ec..1e90d82 100644 --- a/test_tube_scanner/planarian/templates/planarian/import_params.html +++ b/test_tube_scanner/planarian/templates/planarian/import_params.html @@ -3,53 +3,24 @@ {% block content %}
- - -
-
-
- - 📂 {% trans "Importer des configurations depuis CSV" %} - -
- -
-
- - - {% for message in messages %} -
-

{{ message }}

-
- {% endfor %} - +{% if current_session and current_experiment %} + {% include "inc/alert.html" %} +
{% csrf_token %} - - {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -

⚠ {{ error }}

- {% endfor %} -
- {% endif %} - + + +
-

{% trans "Fichier CSV à importer" %}

+

{% trans "Fichier CSV à importer" %}: {{ current_experiment }}

- +

+ {% trans "Colonnes obligatoires : well, px_per_mm, fps." %}
+ {% trans "Toutes les autres colonnes sont optionnelles." %} +

-
- - {% if form.csv_file.errors %} -
- {% for error in form.csv_file.errors %} -

⚠ {{ error }}

- {% endfor %} -
- {% endif %} -
-
- - ✖ {% trans "Annuler" %}
- - @@ -214,18 +168,6 @@ exp_ctrl_01,A2,26.25,10,0.2,1.5,none
exp_light_01,B1,26.25,10,0.2,1.5,fixed
exp_light_01,B2,26.25,10,0.2,1.5,fixed
- - -
- - ⬇ {% trans "Télécharger un template CSV vide" %} - - - {% trans "Ou exportez vos configurations existantes depuis l'admin Django." %} - -
-
@@ -321,5 +263,9 @@ exp_light_01,B2,26.25,10,0.2,1.5,fixed
return (bytes / 1048576).toFixed(1) + ' MB'; } - +{% else %} +
+

{% trans "Choisir une session" %}
{% trans "Puis une expérience." %}

+
+{% endif %} {% endblock %} diff --git a/test_tube_scanner/planarian/urls.py b/test_tube_scanner/planarian/urls.py index f6418cc..1e6697a 100644 --- a/test_tube_scanner/planarian/urls.py +++ b/test_tube_scanner/planarian/urls.py @@ -7,27 +7,13 @@ app_name = "planarian" urlpatterns = [ # Configurations expériences - #path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list2"), - # - #path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"), - #path("experiments//",views.ExperimentConfigFormView.as_view(), name="experiment-edit2"), - - #path("config/list/", views.config_list_view, name="experiment-list"), - #path("config/list///", views.config_list_view, name="experiment-list"), - - #path("config/edit//", views.config_edit_view, name="experiment-edit"), - #path("config/edit////", views.config_edit_view, name="experiment-edit"), - - # Import / export - #path("import/", views.ImportParamsView.as_view(), name="import-params"), - #path("export/", views.ExportCsvView.as_view(), name="export-csv-view"), - - #path("import/", views.ImportParamsView.as_view(), name="import-params"), - path("export/csv/", views.export_csv_view, name="export-csv"), - path("export/csv///", views.export_csv_view, name="export-csv"), - + # Import / export + path("import/csv/", views.import_csv_view, name="import-params"), + path("export/csv/", views.export_csv_view, name="export-csv"), + path("api/export/csv/", views.export_metrics, name="api-export-csv"), # API JSON pour le front-end - path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"), + path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"), + ] diff --git a/test_tube_scanner/planarian/views.py b/test_tube_scanner/planarian/views.py index b77fc27..dde25e2 100644 --- a/test_tube_scanner/planarian/views.py +++ b/test_tube_scanner/planarian/views.py @@ -2,30 +2,27 @@ #import asyncio import logging - +import csv +import io +import json from asgiref.sync import async_to_sync from django.conf import settings from django.contrib import messages -from django.http import HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404, redirect #, render +from django.http import JsonResponse, FileResponse +from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ - -from django.shortcuts import render #, redirect -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.shortcuts import render #, redirect +from django.views.decorators.http import require_GET +from django.contrib.auth.decorators import login_required from django.views import View -from django.views.generic import FormView, ListView - - -from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm -from .models import ExperimentConfig from modules.planarian_metrics import ExperimentParams, ReductStoreClient from modules.system_stats import get_cached_stats, start_background_updater from scanner.constants import ScannerConstants +from .tasks import export_experiment_metrics, export_session_metrics from scanner import models +from .models import ExperimentConfig logger = logging.getLogger(__name__) @@ -61,6 +58,7 @@ def global_context(request, **ctx): domain_server=settings.DOMAIN_SERVER, local_ip_server=settings.LOCAL_IP_SERVER, host_port=settings.SERVER_HOST_PORT, + export_csv_destination=settings.CSV_EXPORT_DIR, conf=conf, **ctx ) @@ -100,6 +98,26 @@ def get_active_session(request, session_id=None, experiment_id=None): # --------------------------------------------------------------------------- # Vue :Export CSV depuis ReductStore # --------------------------------------------------------------------------- + +@login_required +@csrf_exempt +def export_metrics(request): + data = json.loads(request.body.decode() or "{}") + action = data.get("action") + pid = data.get("pid") + if action=='experiment_csv': + experiment = models.Experiment.objects.filter(pk=pid).first() + if experiment: + export_experiment_metrics(experiment) + return JsonResponse({"state": True}) + if action=='session_csv': + session = models.Session.objects.filter(pk=pid).first() + if session: + export_session_metrics(session) + return JsonResponse({"state": True}) + return JsonResponse({"state": False}) + + def export_csv(request): d = request.POST @@ -116,7 +134,7 @@ def export_csv(request): start = d.get("start_dt"), stop = d.get("stop_dt"), ) - print(f"Export CSV: export_csv_response done, {n} lignes, content size={len(csv_content)}, {csv_content}") + print(f"Export CSV: export_csv_response done, {n} lignes, content size={len(csv_content)}") except Exception as e: logger.error(f"Erreur export CSV: {e}") messages.error(request, _("Erreur lors de l'export CSV: %(error)s") % {"error": str(e)}) @@ -124,123 +142,120 @@ def export_csv(request): return csv_content, n csv_content, n = _do_export() - logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}") - if not csv_content: - messages.warning(request, _("Aucune donnée trouvée.")) - return None - filename = ( - f"{d['experiment']}_{d['well']}_planaire{d['planarian']}" + f"{d['experiment']}_{d['well']}-planaire{d['planarian']}" f"_{d['record_type']}.csv" ) - response = HttpResponse(csv_content, content_type="text/csv; charset=utf-8") - response["Content-Disposition"] = f'attachment; filename="{filename}"' - messages.success(request, _("%(n)d lignes exportées.") % {"n": n}) - return response + return csv_content, filename @login_required -def export_csv_view(request, session_id=None, experiment_id=None): - session_context = get_active_session(request, session_id, experiment_id) - - if request.method == 'POST': - valid = request.POST.get('valid') - if valid == 'ok': - export_csv(request) - +def export_csv_view(request): + session_context = get_active_session(request) ctx = { 'choice_title': _("Export vers un fichier CSV depuis ReductStore"), 'well': 'A1', 'planarian': "0", 'record_type': 'frame', **session_context - } + } + if request.method == 'POST': + valid = request.POST.get('valid') + if valid == 'ok': + csv_content, filename = export_csv(request) + if csv_content: + response = FileResponse(csv_content, content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + messages.warning(request, _("Aucune donnée trouvée.")) return render(request, "planarian/export_csv.html", context=global_context(request, **ctx)) # --------------------------------------------------------------------------- # Vue : import CSV de paramètres # --------------------------------------------------------------------------- +def import_csv(request, current_experiment, rows, overwrite): + created = 0 + updated = 0 + errors = 0 + + for row in rows: + try: + params = ExperimentParams.from_csv_row(row) + d = params.to_dict() + + obj, is_new = ExperimentConfig.objects.get_or_create( + experiment = current_experiment.identifier, + well = d.get("well"), + ) + + if is_new or overwrite: + for k, v in d.items(): + if k not in ["well", "experiment", "author", "experiment_key", "active"] and hasattr(obj, k): + setattr(obj, k, v) + obj.save() + if is_new: + created += 1 + else: + updated += 1 + + except Exception as e: + logger.warning(f"Ligne ignorée ({row}): {e}") + errors += 1 + messages.success( + request, + _("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.") + % {"c": created, "u": updated, "e": errors}, + ) + + return redirect("redirect_to_mainboard") + + @login_required -def import_csv_view(request, session_id=None, experiment_id=None): +def import_csv_view(request): """ Import de configurations d'expérience depuis un fichier CSV. Une ligne CSV = un puits = un ExperimentConfig. - Colonnes CSV obligatoires : experiment, well, px_per_mm, fps + Colonnes CSV obligatoires: well, px_per_mm, fps Toutes les autres colonnes correspondent aux champs du modèle. """ - - - - ctx = { - 'choice_title': _("Importer des configurations depuis un fichier CSV"), - } - return render(request, "planarian/import_params.html", context=global_context(request, **ctx)) - - -class ImportParamsView(FormView): - """ - Import de configurations d'expérience depuis un fichier CSV. - Une ligne CSV = un puits = un ExperimentConfig. - - Colonnes CSV obligatoires : experiment, well, px_per_mm, fps - Toutes les autres colonnes correspondent aux champs du modèle. - """ - - template_name = "planarian/import_params.html" - form_class = CsvImportForm - - - def form_valid(self, form): - rows = form.csv_rows - overwrite = form.cleaned_data["overwrite"] - created = 0 - updated = 0 - errors = 0 - - for row in rows: + session_context = get_active_session(request) + if request.method == 'POST': + valid = request.POST.get('valid') + current_experiment = session_context.get('current_experiment') + + if valid == 'ok' and current_experiment: try: - params = ExperimentParams.from_csv_row(row) - d = params.to_dict() - - obj, is_new = ExperimentConfig.objects.get_or_create( - experiment = d["experiment"], - well = d["well"], - ) - - if is_new or overwrite: - for k, v in d.items(): - if k not in ("experiment", "well") and hasattr(obj, k): - setattr(obj, k, v) - obj.save() - if is_new: - created += 1 - else: - updated += 1 + f = request.FILES.get('csv_file') + overwrite = request.POST.get("overwrite") + try: + content = f.read().decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(content)) + rows = list(reader) + except Exception as e: + msg = f'Fichier CSV invalide : {e}' + raise Exception(msg) + required = {"well", "px_per_mm", "fps"} + if rows: + missing = required - set(rows[0].keys()) + if missing: + msg = _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)} + raise Exception(msg) + + return import_csv(request, current_experiment, rows, overwrite) except Exception as e: - logger.warning(f"Ligne ignorée ({row}): {e}") - errors += 1 - - messages.success( - self.request, - _("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.") - % {"c": created, "u": updated, "e": errors}, - ) - return redirect("planarian:experiment-list") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - return global_context(self.request, choice_title=_("Importer des configurations depuis un fichier CSV"), **context) - + messages.error(request, msg) + logger.error(msg) + ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context } + return render(request, "planarian/import_params.html", context=global_context(request, **ctx)) # --------------------------------------------------------------------------- # Vue API JSON : données de tracking (pour polling front-end) # --------------------------------------------------------------------------- - class TrackingDataView(View): """ API JSON retournant les métriques de tracking d'un planaire. diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index 3ac64f5..680e500 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -7,7 +7,6 @@ import json from django_celery_beat.models import PeriodicTask, ClockedSchedule from django.dispatch import receiver from django.db.models.signals import post_save, post_delete -from django.utils.text import slugify from django.utils import timezone from django.db import models from django.contrib.auth.models import User @@ -440,12 +439,14 @@ def create_experiment_well(sender, instance, created, **kwargs): ExperimentWell.objects.get_or_create(experiment=instance, well=wp.well, author=instance.author, defaults={'active':True}) ExperimentConfig.objects.get_or_create( - experiment=instance, - well=wp.well, + experiment_key=instance, + well=wp.well.name, author=instance.author, + experiment=instance.identifier, defaults={ 'px_per_mm': wp.px_per_mm, 'fps': ScannerConstants().get().video_frame_rate, 'well_radius_mm': instance.multiwell.diameter / 2, } - ) + ) + diff --git a/test_tube_scanner/scanner/tasks.py b/test_tube_scanner/scanner/tasks.py index 45c9b92..9b87619 100644 --- a/test_tube_scanner/scanner/tasks.py +++ b/test_tube_scanner/scanner/tasks.py @@ -275,4 +275,5 @@ def supervisor_restart_service(params): except Exception as e: logger.error(f"supervisor_restart_all_services error {e}") + \ No newline at end of file diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index 8e422b8..1161019 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -98,7 +98,12 @@ {% trans "Création de sessions d'expériences" %} -
+
+ {% if conf.tracking %} + + {% trans "Importer des configurations depuis CSV" %} + + {% endif %} {% trans "Gestions des expériences" %} @@ -110,15 +115,15 @@
{% endif %} - {% if conf.tracking %} - - {% trans "Export CSV des expériences" %} - - {% endif %} {% trans "Balayage multi-puits" %}
{% trans "Résultats " %}
+ {% if conf.tracking %} + + {% trans "Export CSV des expériences" %} + + {% endif %} {% trans "Gestionnaire d'images" %} @@ -136,7 +141,7 @@ - + {% endblock %}