diff --git a/test_tube_scanner/home/templates/inc/alert.html b/test_tube_scanner/home/templates/inc/alert.html index 13003ca..f7dfded 100644 --- a/test_tube_scanner/home/templates/inc/alert.html +++ b/test_tube_scanner/home/templates/inc/alert.html @@ -9,5 +9,3 @@ {% endfor %} {% endif %} - - diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py index 28e00fc..e283883 100644 --- a/test_tube_scanner/planarian/admin.py +++ b/test_tube_scanner/planarian/admin.py @@ -13,13 +13,14 @@ class ExperimentConfigAdmin(admin.ModelAdmin): list_display = ("experiment", "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") ordering = ("-created_at",) fieldsets = ( (_("Identification"), { - "fields": ("experiment", "well", "description"), + "fields": ("experiment", "well", "active", "description"), }), (_("Calibration optique: générée lors de la calibration"), { "fields": ("px_per_mm", "fps", "well_radius_mm"), diff --git a/test_tube_scanner/planarian/forms.py b/test_tube_scanner/planarian/forms.py index 05872cd..556b828 100644 --- a/test_tube_scanner/planarian/forms.py +++ b/test_tube_scanner/planarian/forms.py @@ -53,6 +53,7 @@ class CsvImportForm(forms.Form): label=_("Écraser les configurations existantes"), ) + def clean_csv_file(self): f = self.cleaned_data["csv_file"] try: @@ -77,8 +78,14 @@ 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) - planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0) + #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é"))], diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html index 336fe9c..c299f26 100644 --- a/test_tube_scanner/planarian/templates/planarian/base.html +++ b/test_tube_scanner/planarian/templates/planarian/base.html @@ -19,7 +19,7 @@
{% for ss in experiments %} - + {% endfor %}
diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_form.html b/test_tube_scanner/planarian/templates/planarian/experiment_form.html index 193d130..1e1a850 100644 --- a/test_tube_scanner/planarian/templates/planarian/experiment_form.html +++ b/test_tube_scanner/planarian/templates/planarian/experiment_form.html @@ -54,14 +54,6 @@
- -
- - {{ form.identifier }} - {% if form.identifier.errors %} - {{ form.identifier.errors|join:", " }} - {% endif %} -
diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_list.html b/test_tube_scanner/planarian/templates/planarian/experiment_list.html index b6e836e..f848360 100644 --- a/test_tube_scanner/planarian/templates/planarian/experiment_list.html +++ b/test_tube_scanner/planarian/templates/planarian/experiment_list.html @@ -3,7 +3,6 @@ {% block content %}
-
@@ -21,7 +20,6 @@
- {% for message in messages %}
🔀 {% endif %} - {{ cfg.created_at|date:"d/m/Y H:i" }} - + {% if current_session and current_experiment %} + - - - - - - {% for message in messages %} -
-

{{ message }}

-
- {% endfor %} - +{% if current_session and current_experiment %} + {% include "inc/alert.html" %}

@@ -44,89 +15,54 @@ {% 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 %} - - {% if form.experiment.errors %} - - {% endif %} + +
-
- - + {% for well in well_choices %} + {% endfor %} - {% 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 %} +
-
-

@@ -136,36 +72,26 @@

-
- - {{ 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" %} @@ -173,7 +99,6 @@
-
@@ -209,9 +134,7 @@
-
- - +{% else %} +
+

{% trans "Choisir une session" %}

+
+{% endif %} {% endblock %} diff --git a/test_tube_scanner/planarian/templates/planarian/export_csv2.html b/test_tube_scanner/planarian/templates/planarian/export_csv2.html new file mode 100644 index 0000000..6e4ff43 --- /dev/null +++ b/test_tube_scanner/planarian/templates/planarian/export_csv2.html @@ -0,0 +1,233 @@ +{% 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/urls.py b/test_tube_scanner/planarian/urls.py index dd9c784..f6418cc 100644 --- a/test_tube_scanner/planarian/urls.py +++ b/test_tube_scanner/planarian/urls.py @@ -7,13 +7,25 @@ app_name = "planarian" urlpatterns = [ # Configurations expériences - path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list"), - path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"), - path("experiments//",views.ExperimentConfigFormView.as_view(), name="experiment-edit"), + #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"), + #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"), + # API JSON pour le front-end 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 0e532f6..b77fc27 100644 --- a/test_tube_scanner/planarian/views.py +++ b/test_tube_scanner/planarian/views.py @@ -9,9 +9,15 @@ from django.contrib import messages from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect #, render 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.views import View from django.views.generic import FormView, ListView -from django.views.decorators.http import require_GET + from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm from .models import ExperimentConfig @@ -23,10 +29,13 @@ from scanner import models logger = logging.getLogger(__name__) - start_background_updater() - + +def is_staff_or_admin(user): + return user.is_staff or user.is_superuser + + @require_GET def stats_view(request): """ @@ -45,7 +54,6 @@ def _get_reduct_client() -> ReductStoreClient: def global_context(request, **ctx): - default_multiwell = models.MultiWell.objects.filter(default=True).first() conf = ScannerConstants().get() return dict( app_title=settings.APP_TITLE, @@ -54,10 +62,6 @@ def global_context(request, **ctx): local_ip_server=settings.LOCAL_IP_SERVER, host_port=settings.SERVER_HOST_PORT, conf=conf, - default_position = default_multiwell.position or 'HD', - export_destination=settings.EXPORT_DESTINATIONS, - well_choices = models.Well.objects.order_by('name').all(), - **ctx ) @@ -78,104 +82,104 @@ def get_active_experiments(session, expid=None): return [], None -def get_experiment_config(request): - cursid = request.POST.get('_sid') - expid = request.POST.get('_expid') - - print(request.POST) - +def get_active_session(request, session_id=None, experiment_id=None): + cursid = session_id or request.POST.get('_sid') + expid = experiment_id or request.POST.get('_expid') + current_session = models.Session.get_session(cursid) - experiments, current_experiment = get_active_experiments(current_session, expid) - - qs = ExperimentConfig.objects - if not current_session: - qs = qs.filter(experiment__session_experiments__isnull=False).select_related() - elif current_experiment: - qs = qs.filter(experiment_id=current_experiment.id) - + experiments, current_experiment = get_active_experiments(current_session, expid) context = dict( current_session = current_session, current_experiment = current_experiment, experiments=experiments or [], sessions=models.Session.objects.filter(active=True).all(), + well_choices=models.Well.objects.order_by('name').all(), ) - return qs.all(), context - -# --------------------------------------------------------------------------- -# Vue : liste des configurations -# --------------------------------------------------------------------------- - -class ExperimentConfigListView(ListView): - """Liste toutes les configurations expériences.""" - - model = ExperimentConfig - template_name = "planarian/experiment_list.html" - context_object_name = "configs" - ordering = ["-created_at"] - - def get_queryset(self): - qs, self.config_context = get_experiment_config(self.request) - return qs - - def get(self, request, *args, **kwargs): - return super().get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - return super().get(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context.update(self.config_context) - return global_context(self.request, **context) + return context # --------------------------------------------------------------------------- -# Vue : création / modification d'une configuration +# Vue :Export CSV depuis ReductStore # --------------------------------------------------------------------------- - -class ExperimentConfigFormView(FormView): - """Formulaire de saisie des paramètres d'une expérience.""" - - template_name = "planarian/experiment_form.html" - form_class = ExperimentConfigForm +def export_csv(request): + d = request.POST - - def get_queryset(self): - qs, self.config_context = get_experiment_config(self.request) - return qs + @async_to_sync + async def _do_export(): + client = _get_reduct_client() + await client.connect() + try: + csv_content, n = await client.export_csv_response( + experiment = d.get("experiment"), + well = d.get("well"), + planarian = d.get("planarian"), + record_type = d.get("record_type"), + 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}") + 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)}) + return None, 0 + return csv_content, n - def get_form(self, form_class=None): - pk = self.kwargs.get("pk") - if pk: - instance = get_object_or_404(ExperimentConfig, pk=pk) - return ExperimentConfigForm(self.request.POST or None, instance=instance) - return ExperimentConfigForm(self.request.POST or None) + csv_content, n = _do_export() - def form_valid(self, form): - form.save() - messages.success(self.request, _("Configuration sauvegardée.")) - return redirect("planarian:experiment-list") - - def form_invalid(self, form): - print(f"Form validation failed: {form.errors}") - messages.error(self.request, form.errors) - return super().form_invalid(form) + 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 - def post(self, request, *args, **kwargs): - print(f"Received POST data: {request.POST}") - return super().post(request, *args, **kwargs) + filename = ( + 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 + +@login_required +def export_csv_view(request, session_id=None, experiment_id=None): + session_context = get_active_session(request, session_id, experiment_id) - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['is_creation'] = 'pk' not in self.kwargs - context['is_update'] = 'pk' in self.kwargs - return global_context(self.request, choice_title=_("Paramètres d'une expérience"), **context) - + if request.method == 'POST': + valid = request.POST.get('valid') + if valid == 'ok': + export_csv(request) + + ctx = { + 'choice_title': _("Export vers un fichier CSV depuis ReductStore"), + 'well': 'A1', + 'planarian': "0", + 'record_type': 'frame', + **session_context + } + + return render(request, "planarian/export_csv.html", context=global_context(request, **ctx)) + # --------------------------------------------------------------------------- # Vue : import CSV de paramètres # --------------------------------------------------------------------------- +@login_required +def import_csv_view(request, session_id=None, experiment_id=None): + """ + 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. + """ + + + + 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): """ @@ -189,9 +193,6 @@ class ImportParamsView(FormView): template_name = "planarian/import_params.html" form_class = CsvImportForm - def get_queryset(self): - qs, self.config_context = get_experiment_config(self.request) - return qs def form_valid(self, form): rows = form.csv_rows @@ -233,69 +234,8 @@ class ImportParamsView(FormView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - return global_context(self.request, choice_title=_("configurations d'expérience depuis un fichier CSV"), **context) - - - -# --------------------------------------------------------------------------- -# Vue : export CSV depuis ReductStore -# --------------------------------------------------------------------------- - -class ExportCsvView(FormView): - """ - Export des données de tracking depuis ReductStore vers un fichier CSV. - Retourne le fichier en téléchargement HTTP. - """ - - template_name = "planarian/export_csv.html" - form_class = ExportCsvForm - - def form_valid(self, form): - d = form.cleaned_data - - @async_to_sync - async def _do_export(): - client = _get_reduct_client() - await client.connect() - try: - csv_content, n = await client.export_csv_response( - experiment = d["experiment"], - well = d["well"], - planarian = d["planarian"], - record_type = d["record_type"], - 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}") - except Exception as e: - logger.error(f"Erreur export CSV: {e}") - messages.error(self.request, _("Erreur lors de l'export CSV: %(error)s") % {"error": str(e)}) - return None, 0 - - return csv_content, n - - csv_content, n = _do_export() - - print(f"Export CSV: {n} lignes, content size={len(csv_content)}") - if not csv_content: - messages.warning(self.request, _("Aucune donnée trouvée.")) - return self.form_invalid(form) - - filename = ( - 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(self.request, _("%(n)d lignes exportées.") % {"n": n}) - return response - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - qs, config_context = get_experiment_config(self.request) - context.update(config_context) - return global_context(self.request, choice_title=_("Tracking depuis ReductStore vers un fichier CSV"), **context) - + return global_context(self.request, choice_title=_("Importer des configurations depuis un fichier CSV"), **context) + # --------------------------------------------------------------------------- # Vue API JSON : données de tracking (pour polling front-end) @@ -337,5 +277,5 @@ class TrackingDataView(View): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - return global_context(self.request, choice_title=_("Métriques de tracking d'un planaire"), **context) + return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context) diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index 9bd2e1e..8e422b8 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -99,20 +99,20 @@ {% trans "Création de sessions d'expériences" %}
+ + {% trans "Gestions des expériences" %} + {% trans "Configuration des expériences" %} - - {% trans "Gestions des expériences" %} - - {% trans "Calibration" %} + {% trans "Calibration" %}
{% endif %} {% if conf.tracking %} - - {% trans "Préparation des expériences" %} + + {% trans "Export CSV des expériences" %} {% endif %}