export/import metrics

This commit is contained in:
2026-05-13 13:21:49 +02:00
parent 15c01c483f
commit 01acef913b
17 changed files with 293 additions and 1426 deletions
@@ -2,10 +2,14 @@
{% if messages %} {% if messages %}
<div class="alert"> <div class="alert">
{% for message in messages %} {% for message in messages %}
<div class="w3-bar {% if message.tags %}{{ message.tags }}{% endif %}" style="width: 100%" > <div class="w3-panel w3-round
<div class="w3-bar-item">{{ message }}</div> {% if message.tags == 'success' %}w3-green
<div class="w3-bar-item w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</div> {% elif message.tags == 'error' %}w3-red
</div> {% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}" style="width: 100%" >
<div class="w3-bar-item">{{ message }}</div>
<div class="w3-bar-item w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -618,7 +618,7 @@ class ExperimentParams:
def from_csv_file(cls, filepath: str) -> list: def from_csv_file(cls, filepath: str) -> list:
"""Charge toutes les expériences d'un fichier CSV.""" """Charge toutes les expériences d'un fichier CSV."""
results = [] 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): for row in csv.DictReader(f):
try: try:
results.append(cls.from_csv_row(row)) results.append(cls.from_csv_row(row))
+8 -9
View File
@@ -8,19 +8,18 @@ from .models import ExperimentConfig
@admin.register(ExperimentConfig) @admin.register(ExperimentConfig)
class ExperimentConfigAdmin(admin.ModelAdmin): class ExperimentConfigAdmin(admin.ModelAdmin):
"""Admin Django pour les configurations d'expérience.""" """Admin Django pour les configurations d'expérience."""
#readonly_fields = ('experiment', ) readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',)
readonly_fields = ('px_per_mm', 'fps', 'well_radius_mm',) list_display = ("experiment_key", "well", "active", "px_per_mm", "fps",
list_display = ("experiment", "well", "active", "px_per_mm", "fps",
"thresh_immobile", "thresh_mobile", "thresh_immobile", "thresh_mobile",
"photo_mode", "chemo_strength", "created_at", ) "photo_mode", "chemo_strength", "created_at", )
list_filter = ("experiment", "photo_mode", "tube_axis") list_filter = ("experiment_key", "photo_mode", "tube_axis")
search_fields = ("experiment", "well", "description") search_fields = ("experiment_key", "well_name", "description")
ordering = ("-created_at",) ordering = ("-created_at",)
fieldsets = ( fieldsets = (
(_("Identification"), { (_("Identification"), {
"fields": ("experiment", "well", "active", "description"), "fields": ("experiment_key", "well", "active", "description"),
}), }),
(_("Calibration optique: générée lors de la calibration"), { (_("Calibration optique: générée lors de la calibration"), {
"fields": ("px_per_mm", "fps", "well_radius_mm"), "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")) @admin.action(description=_("Exporter un template CSV de ces configurations"))
def export_csv_template(self, request, queryset): def export_csv_template(self, request, queryset):
import csv import csv
from django.http import HttpResponse from django.http import FileResponse
from io import StringIO from io import StringIO
output = StringIO() output = StringIO()
fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable
writer = csv.DictWriter(output, fieldnames=fields) writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader() writer.writeheader()
for obj in queryset: for obj in queryset:
row = {f: getattr(obj, f) for f in fields} row = {f: getattr(obj, f) for f in fields}
writer.writerow(row) writer.writerow(row)
response = HttpResponse(output.getvalue(), content_type="text/csv") response = FileResponse(output.getvalue(), content_type='text/csv')
response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"' response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"'
return response return response
-104
View File
@@ -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"}),
)
+25 -9
View File
@@ -6,6 +6,12 @@ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from scanner.models import Experiment, Well 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): 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. 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) 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) experiment_key = models.ForeignKey(Experiment, verbose_name="Expérience", on_delete=models.CASCADE, null=True, blank=False)
well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", 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="-") 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) active = models.BooleanField(_("Active"), default=True)
# --- Calibration optique --- # --- Calibration optique ---
@@ -117,24 +125,32 @@ class ExperimentConfig(models.Model):
avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (mm)")) 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)")) aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)"))
class Meta: class Meta:
verbose_name = _("Configuration d'une expérience") verbose_name = _("Configuration d'une expérience")
verbose_name_plural = _("Configurations des expériences") verbose_name_plural = _("Configurations des expériences")
unique_together = ("experiment", "well") unique_together = ("experiment_key", "well")
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self): 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): 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: def to_params_dict(self) -> dict:
"""Retourne un dict compatible avec ExperimentParams.""" """Retourne un dict compatible avec ExperimentParams."""
return { return {
"experiment": self.experiment.identifier, "experiment": self.experiment,
"well": self.well.name, "well": self.well,
"px_per_mm": self.px_per_mm, "px_per_mm": self.px_per_mm,
"fps": self.fps, "fps": self.fps,
"well_radius_mm": self.well_radius_mm, "well_radius_mm": self.well_radius_mm,
+54
View File
@@ -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)
@@ -23,15 +23,15 @@
{% endfor %} {% endfor %}
</div> </div>
</form> </form>
{% if current_session.id and current_experiment.id %} {% if current_session and current_experiment %}
<hr class="w3-bar-item divider w3-grey"> {% url "planarian:api-export-csv" as url_export %}
<form method="post" class="w3-bar-item" action="{% url 'planarian:export-csv' %}"> <a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="export_csv('{{ url_export }}', {{ current_experiment.id }}, 'experiment_csv')">
{% csrf_token %} <i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter les metrics de l'expérience" %}<br>
<input type="hidden" name="_sid" value="{{ current_session.id }}"> <span class="w3-margin-left-5">&nbsp;$> {{ export_csv_destination }}
<input type="hidden" name="_expid" value="{{ current_experiment.id }}"> </a>
<button type="submit" class="w3-button w3-blue w3-round w3-padding w3-margin-top">📥 {% trans "Exporter CSV" %}</button>
</form>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block js_footer %} {% block js_footer %}
@@ -53,5 +53,20 @@
}, 15000); // 15 seconds }, 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); });
}
</script> </script>
{% endblock %} {% endblock %}
@@ -1,540 +0,0 @@
{% extends "planarian/base.html" %}
{% load i18n %}
{% block content %}
<div class="w3-container w3-padding-small" style="max-width:960px; margin:auto;">
<!-- En-tête de la page -->
<div class="w3-panel w3-teal w3-round-large w3-padding-16" style="margin-bottom:2rem;">
<div class="w3-row w3-bar-item">
<span class="w3-xlarge">
<i class="w3-margin-right">🔬</i>
{% if object %}
{% trans "Modifier la configuration" %} — {{ object }}
{% else %}
{% trans "Nouvelle configuration d'expérience" %}
{% endif %}
</span>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<form method="post" novalidate>
{% csrf_token %}
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
<!-- Erreurs globales du formulaire -->
{% if form.non_field_errors %}
<div class="w3-panel w3-red w3-round">
{% for error in form.non_field_errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<!-- ============================================================
Section 1 : Identification
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-teal w3-round w3-round-large">
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
</header>
<div class="w3-container w3-padding">
<div class="w3-row w3-row-padding">
<!-- author -->
<div class="w3-half w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.author.label }}</b></label>
{{ form.author }}
{% if form.author.errors %}
<span class="w3-text-red w3-small">{{ form.author.errors|join:", " }}</span>
{% endif %}
</div>
<!-- Experiment -->
<div class="w3-half w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.experiment.label }}</b></label>
{{ form.experiment }}
{% if form.experiment.errors %}
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
{% endif %}
</div>
<!-- Well -->
<div class="w3-half w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.well.label }}</b></label>
{{ form.well }}
{% if form.well.errors %}
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
{% endif %}
</div>
</div>
<!-- Description -->
<div class="w3-margin-bottom w3-padding">
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
{{ form.description }}
{% if form.description.errors %}
<span class="w3-text-red w3-small">{{ form.description.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
<!-- ============================================================
Section 2 : Calibration optique
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<div class="w3-row w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
{% if form.px_per_mm.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.px_per_mm.help_text }}</p>
{% endif %}
{{ form.px_per_mm }}
{% if form.px_per_mm.errors %}
<span class="w3-text-red w3-small">{{ form.px_per_mm.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
{% if form.fps.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.fps.help_text }}</p>
{% endif %}
{{ form.fps }}
{% if form.fps.errors %}
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
{% if form.well_radius_mm.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.well_radius_mm.help_text }}</p>
{% endif %}
{{ form.well_radius_mm }}
{% if form.well_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 4 : Tracker
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-dark-grey w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Tracker" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<div class="w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.tube_axis.label }}</b></label>
{{ form.tube_axis }}
{% if form.tube_axis.errors %}
<span class="w3-text-red w3-small">{{ form.tube_axis.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.min_area_px.label }}</b></label>
{{ form.min_area_px }}
{% if form.min_area_px.errors %}
<span class="w3-text-red w3-small">{{ form.min_area_px.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.max_area_ratio.label }}</b></label>
{{ form.max_area_ratio }}
{% if form.max_area_ratio.errors %}
<span class="w3-text-red w3-small">{{ form.max_area_ratio.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.planarian_count.label }}</b></label>
{{ form.planarian_count }}
{% if form.planarian_count.errors %}
<span class="w3-text-red w3-small">{{ form.planarian_count.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.min_contour_dist_px.label }}</b></label>
{{ form.min_contour_dist_px }}
{% if form.min_contour_dist_px.errors %}
<span class="w3-text-red w3-small">{{ form.min_contour_dist_px.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-grey"><b>{{ form.merge_kernel_size.label }}</b></label>
{{ form.merge_kernel_size }}
{% if form.merge_kernel_size.errors %}
<span class="w3-text-red w3-small">{{ form.merge_kernel_size.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 3 : Seuils de mobilité EthoVision
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-indigo w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<!-- Visualisation des seuils -->
<div class="w3-light-grey w3-round w3-margin-bottom" style="height:32px; position:relative; overflow:hidden;">
<div style="position:absolute; left:0; width:13%; height:100%; background:#c0392b; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Immobile" %}</b></span>
</div>
<div style="position:absolute; left:13%; width:27%; height:100%; background:#e67e22; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Mobile" %}</b></span>
</div>
<div style="position:absolute; left:40%; right:0; height:100%; background:#27ae60; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Très mobile" %}</b></span>
</div>
</div>
<p class="w3-small w3-text-grey" style="margin-top:-8px; margin-bottom:16px;">
{% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %}
</p>
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-indigo"><b>{{ form.thresh_immobile.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Immobile (mm/s)" %}</p>
{{ form.thresh_immobile }}
{% if form.thresh_immobile.errors %}
<span class="w3-text-red w3-small">{{ form.thresh_immobile.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-indigo"><b>{{ form.thresh_mobile.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}</p>
{{ form.thresh_mobile }}
{% if form.thresh_mobile.errors %}
<span class="w3-text-red w3-small">{{ form.thresh_mobile.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 5 : Comportements
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-teal w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
</header>
<!-- --- Thigmotactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('thigmo')">
<span class="w3-large">🫧</span>
<b class="w3-margin-left">{% trans "Thigmotactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Attraction vers la paroi du puits" %}
</span>
<span id="thigmo-icon" class="w3-right"></span>
</button>
<div id="thigmo" class="w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12">
<label class="w3-text-teal"><b>{{ form.thigmotaxis_wall_dist_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
{% trans "Distance à la paroi considérée « près du bord » (mm)" %}
</p>
{{ form.thigmotaxis_wall_dist_mm }}
{% if form.thigmotaxis_wall_dist_mm.errors %}
<span class="w3-text-red w3-small">{{ form.thigmotaxis_wall_dist_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Phototactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey" onclick="toggleSection('photo')">
<span class="w3-large">💡</span>
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Fuite de la lumière" %}
</span>
<span id="photo-icon" class="w3-right"></span>
</button>
<div id="photo" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">&nbsp;</p>
{{ form.photo_mode }}
{% if form.photo_mode.errors %}
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_strength.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
{{ form.photo_strength }}
{% if form.photo_strength.errors %}
<span class="w3-text-red w3-small">{{ form.photo_strength.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">&nbsp;</p>
{{ form.photo_x }}
{% if form.photo_x.errors %}
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">&nbsp;</p>
{{ form.photo_y }}
{% if form.photo_y.errors %}
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Chimiotactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('chemo')">
<span class="w3-large">🍖</span>
<b class="w3-margin-left">{% trans "Chimiotactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Attraction vers une source de nourriture" %}
</span>
<span id="chemo-icon" class="w3-right"></span>
</button>
<div id="chemo" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_strength.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
{{ form.chemo_strength }}
{% if form.chemo_strength.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_strength.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Mini = 0, maxi = 1" %}</p>
{{ form.chemo_x }}
{% if form.chemo_x.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Mini = 0, maxi = 1" %}</p>
{{ form.chemo_y }}
{% if form.chemo_y.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_radius_mm.label }}</b></label>
{{ form.chemo_radius_mm }}
{% if form.chemo_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Interactions inter-individus --- -->
<div class="w3-container w3-padding-16">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('social')">
<span class="w3-large">🔀</span>
<b class="w3-margin-left">{% trans "Interactions inter-individus" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Évitement et agrégation" %}
</span>
<span id="social-icon" class="w3-right"></span>
</button>
<div id="social" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.avoid_radius_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon de répulsion courte portée (mm)" %}</p>
{{ form.avoid_radius_mm }}
{% if form.avoid_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.avoid_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.aggreg_radius_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}</p>
{{ form.aggreg_radius_mm }}
{% if form.aggreg_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.aggreg_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- fin card comportements -->
<!-- ============================================================
Boutons d'action
============================================================ -->
<div class="w3-row-padding w3-margin-top">
<div class="w3-col m8 s12 w3-margin-bottom">
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
{% if is_update %}
💾 {% trans "Enregistrer les modifications" %}
{% else %}
{% trans "Créer la configuration" %}
{% endif %}
</button>
<a href="{% url 'planarian:experiment-list' %}"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Retour" %}
</a>
</div>
{% if object %}
<div class="w3-col m4 s12 w3-right-align w3-margin-bottom">
<a href="{% url 'planarian:export-csv' %}?experiment={{ object.experiment }}&well={{ object.well }}"
class="w3-button w3-blue w3-round w3-large w3-padding-large">
📥 {% trans "Exporter CSV" %}
</a>
</div>
{% endif %}
</div>
</form><!-- fin form -->
</div><!-- fin container -->
<!-- ============================================================
Styles W3.CSS pour les champs de formulaire Django
(Django génère des <input> bruts sans classes — on les stylise ici)
============================================================ -->
<style>
/* Champs texte, number, select */
input[type="text"],
input[type="number"],
input[type="email"],
textarea,
select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 15px;
box-sizing: border-box;
margin-top: 2px;
background-color: #fafafa;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus,
select:focus {
border-color: #009688; /* teal W3.CSS */
outline: none;
background-color: #fff;
}
/* Champ en erreur */
input.errorfield, select.errorfield, textarea.errorfield {
border-color: #f44336;
background-color: #fff8f8;
}
/* Label au-dessus du champ */
label {
display: block;
margin-bottom: 2px;
font-size: 14px;
}
/* Sections accordéon */
[id$="-icon"] {
transition: transform 0.2s;
}
</style>
<script>
/**
* Bascule la visibilité d'une section accordéon.
* @param {string} id - Identifiant de la section (sans le préfixe)
*/
function toggleSection(id) {
const section = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
if (section.classList.contains('w3-hide')) {
section.classList.remove('w3-hide');
if (icon) icon.textContent = '▲';
} else {
section.classList.add('w3-hide');
if (icon) icon.textContent = '▼';
}
}
/* Ouvrir automatiquement les sections qui contiennent des erreurs */
document.addEventListener('DOMContentLoaded', function () {
['thigmo', 'photo', 'chemo', 'social'].forEach(function (id) {
const section = document.getElementById(id);
if (section && section.querySelector('.w3-text-red')) {
section.classList.remove('w3-hide');
const icon = document.getElementById(id + '-icon');
if (icon) icon.textContent = '▲';
}
});
});
</script>
{% endblock %}
@@ -1,312 +0,0 @@
{% extends "planarian/base.html" %}
{% load i18n home_tags scanner_tags %}
{% block content %}
<div class="w3-container w3-padding" style="max-width:1200px; margin:auto;">
<!-- En-tête -->
<div class="w3-panel w3-teal w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
<i class="w3-margin-right">🔬</i>
{% trans "Configurations d'expériences" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="{% url 'planarian:experiment-new' %}"
class="w3-button w3-white w3-round w3-text-teal">
{% trans "Nouvelle configuration" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round alert
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<button class="w3-btn w3-right w3-bold w3-black w3-round w3-margin" onclick="s('.alert').remove()">X</button>
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Barre d'outils : recherche + import -->
<div class="w3-row-padding w3-margin-bottom">
<!-- Recherche côté client -->
<div class="w3-col m7 s12 w3-margin-bottom">
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
<span style="padding:0 10px; font-size:18px; color:#888;">🔍</span>
<input type="text" id="search-input"
placeholder="{% trans 'Filtrer par expérience, puits…' %}"
class="w3-input w3-border-0"
style="background:transparent;"
oninput="filterTable(this.value)">
</div>
</div>
<!-- Boutons import / export -->
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
<a href="{% url 'planarian:import-params' %}"
class="w3-button w3-blue-grey w3-round w3-margin-right">
📂 {% trans "Importer CSV" %}
</a>
<a href="{% url 'planarian:export-csv' %}"
class="w3-button w3-blue w3-round">
📥 {% trans "Exporter CSV" %}
</a>
</div>
</div>
<!-- ============================================================
Tableau des configurations
============================================================ -->
{% if configs %}
<!-- Compteur -->
<p class="w3-text-grey w3-small" id="row-count">
{{ configs|length }} {% trans "configuration(s)" %}
</p>
<div class="w3-responsive w3-card-4 w3-round-large">
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
<thead>
<tr class="w3-teal">
<th onclick="sortTable(0)" class="sortable" title="{% trans 'Trier' %}">
{% trans "Expérience" %} <span class="sort-icon"></span>
</th>
<th onclick="sortTable(1)" class="sortable" title="{% trans 'Trier' %}">
{% trans "Puits" %} <span class="sort-icon"></span>
</th>
<th onclick="sortTable(2)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
{% trans "px/mm" %} <span class="sort-icon"></span>
</th>
<th class="w3-hide-small">{% trans "FPS" %}</th>
<th class="w3-hide-small">{% trans "Seuils (mm/s)" %}</th>
<th class="w3-hide-small">{% trans "Comportements" %}</th>
<th onclick="sortTable(6)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
{% trans "Créé le" %} <span class="sort-icon"></span>
</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody id="config-tbody">
{% for cfg in configs %}
<tr class="config-row">
<!-- Expérience -->
<td>
<b><span class="w3-text-grey">{{ cfg.experiment }}</span></b>
{% if cfg.description %}
<br><span class="w3-small w3-text-blue-grey">{{ cfg.description|truncatechars:40 }}</span>
{% endif %}
</td>
<!-- Puits -->
<td>
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
</td>
<!-- px/mm -->
<td class="w3-hide-small w3-text-grey">{{ cfg.px_per_mm }}</td>
<!-- FPS -->
<td class="w3-hide-small w3-text-grey">{{ cfg.fps }}</td>
<!-- Seuils de mobilité -->
<td class="w3-hide-small">
<span class="w3-tag w3-red w3-round w3-small"
title="{% trans 'Immobile' %}">
&lt; {{ cfg.thresh_immobile }}
</span>
<span class="w3-tag w3-orange w3-round w3-small"
title="{% trans 'Mobile' %}">
&lt; {{ cfg.thresh_mobile }}
</span>
</td>
<!-- Comportements actifs -->
<td class="w3-hide-small">
{% if cfg.thigmotaxis_wall_dist_mm > 0 %}
<span class="w3-tag w3-blue-grey w3-round w3-small w3-margin-right"
title="{% trans 'Thigmotactisme actif' %}">🫧</span>
{% endif %}
{% if cfg.photo_mode != 'none' and cfg.photo_strength > 0 %}
<span class="w3-tag w3-yellow w3-round w3-small w3-margin-right"
title="{% trans 'Phototactisme actif' %} ({{ cfg.photo_mode }})">💡</span>
{% endif %}
{% if cfg.chemo_strength > 0 %}
<span class="w3-tag w3-green w3-round w3-small w3-margin-right"
title="{% trans 'Chimiotactisme actif' %}">🍖</span>
{% endif %}
{% if cfg.avoid_radius_mm > 0 or cfg.aggreg_radius_mm > 0 %}
<span class="w3-tag w3-purple w3-round w3-small"
title="{% trans 'Interactions inter-individus' %}">🔀</span>
{% endif %}
</td>
<!-- Date de création -->
<td class="w3-hide-small w3-small w3-text-grey">
{{ cfg.created_at|date:"d/m/Y H:i" }}
</td>
<!-- Actions -->
<td style="white-space:nowrap;">
{% if current_session and current_experiment %}
<a href="{% url 'planarian:experiment-edit' cfg.pk current_session.id current_experiment.id %}"
{% else %}
<a href="{% url 'planarian:experiment-edit' cfg.pk %}"
{% endif %}
class="w3-button w3-small w3-teal w3-round"
title="{% trans 'Modifier' %}"></a>
<a href="{% url 'planarian:export-csv' %}?experiment={{ cfg.experiment }}&well={{ cfg.well }}"
class="w3-button w3-small w3-blue w3-round"
title="{% trans 'Exporter CSV' %}">📥</a>
<button type="button"
class="w3-button w3-small w3-red w3-round"
title="{% trans 'Supprimer' %}"
onclick="confirmDelete('{{ cfg.pk }}', '{{ cfg.experiment }}', '{{ cfg.well }}')"> 🗑 </button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div><!-- fin responsive -->
<!-- Message "aucun résultat" (filtrage JS) -->
<div id="no-results" class="w3-panel w3-pale-yellow w3-round w3-margin-top" style="display:none;">
<p>{% trans "Aucune configuration ne correspond à la recherche." %}</p>
</div>
{% else %}
<!-- Liste vide -->
<div class="w3-panel w3-pale-blue w3-round-large w3-padding-32" style="text-align:center;">
<p style="font-size:3rem; margin:0;">🔬</p>
<h3>{% trans "Aucune configuration pour l'instant." %}</h3>
<p class="w3-text-grey">
{% trans "Créez une première configuration ou importez un fichier CSV." %}
</p>
<a href="{% url 'planarian:experiment-new' %}"
class="w3-button w3-teal w3-round w3-large w3-margin-right">
{% trans "Nouvelle configuration" %}
</a>
<a href="{% url 'planarian:import-params' %}" class="w3-button w3-blue-grey w3-round w3-large"> 📂 {% trans "Importer CSV" %} </a>
</div>
{% endif %}
</div><!-- fin container -->
<!-- ============================================================
Modal de confirmation de suppression
============================================================ -->
<div id="delete-modal" class="w3-modal">
<div class="w3-modal-content w3-round-large w3-card-4" style="max-width:420px; margin:auto; margin-top:10%;">
<header class="w3-container w3-red w3-round-top-large">
<h3 class="w3-text-white">🗑 {% trans "Confirmer la suppression" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<p id="delete-msg"></p>
<form id="delete-form" method="post" action="">
{% csrf_token %}
<input type="hidden" name="_method" value="delete">
<button type="submit" class="w3-button w3-red w3-round w3-margin-right"> 🗑 {% trans "Supprimer" %}</button>
<button type="button" class="w3-button w3-light-grey w3-round"
onclick="document.getElementById('delete-modal').style.display='none'">
✖ {% trans "Annuler" %}
</button>
</form>
</div>
</div>
</div>
<!-- ============================================================
Styles
============================================================ -->
<style>
/* En-têtes de colonnes triables */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background-color: #00796b;
}
.sort-icon {
font-size: 11px;
opacity: 0.7;
margin-left: 4px;
}
th.sort-asc .sort-icon::after { content: " ▲"; opacity: 1; }
th.sort-desc .sort-icon::after { content: " ▼"; opacity: 1; }
/* Tableau responsive */
#config-table td, #config-table th {
vertical-align: middle;
padding: 10px 12px;
}
</style>
<!-- ============================================================
JavaScript : filtrage + tri + modal suppression
============================================================ -->
<script>
// --- Filtrage dynamique ---
function filterTable(query) {
const q = query.toLowerCase().trim();
const rows = document.querySelectorAll('.config-row');
const noRes = document.getElementById('no-results');
const count = document.getElementById('row-count');
let visible = 0;
rows.forEach(function (row) {
const text = row.textContent.toLowerCase();
if (!q || text.includes(q)) {
row.style.display = '';
visible++;
} else {
row.style.display = 'none';
}
});
noRes.style.display = (visible === 0 && q) ? 'block' : 'none';
if (count) {
count.textContent = visible + ' {% trans "configuration(s)" %}';
}
}
// --- Tri de colonnes ---
let sortDir = {};
function sortTable(colIdx) {
const tbody = document.getElementById('config-tbody');
const rows = Array.from(tbody.querySelectorAll('.config-row'));
const th = document.querySelectorAll('#config-table thead th')[colIdx];
// Alterner ascendant / descendant
sortDir[colIdx] = sortDir[colIdx] === 'asc' ? 'desc' : 'asc';
// Réinitialiser les classes de toutes les colonnes
document.querySelectorAll('#config-table thead th').forEach(function (h) {
h.classList.remove('sort-asc', 'sort-desc');
});
th.classList.add(sortDir[colIdx] === 'asc' ? 'sort-asc' : 'sort-desc');
rows.sort(function (a, b) {
const cellA = a.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
const cellB = b.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
// Tri numérique si possible, sinon alphabétique
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
const cmp = (!isNaN(numA) && !isNaN(numB))
? numA - numB
: cellA.localeCompare(cellB, '{{ LANGUAGE_CODE|default:"fr" }}');
return sortDir[colIdx] === 'asc' ? cmp : -cmp;
});
rows.forEach(function (row) { tbody.appendChild(row); });
}
// --- Modal de suppression ---
function confirmDelete(pk, experiment, well) {
document.getElementById('delete-msg').textContent =
'{% trans "Supprimer la configuration" %} ' + experiment + ' / ' + well + ' ?';
document.getElementById('delete-form').action =
'{% url "planarian:experiment-list" %}' + pk + '/delete/';
document.getElementById('delete-modal').style.display = 'block';
}
// Fermer le modal en cliquant en dehors
window.onclick = function (e) {
const modal = document.getElementById('delete-modal');
if (e.target === modal) modal.style.display = 'none';
};
</script>
{% endblock %}
@@ -21,7 +21,6 @@
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}"> <input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}"> <input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
<div class="w3-card-4 w3-border w3-round w3-round-large"> <div class="w3-card-4 w3-border w3-round w3-round-large">
<header class="w3-container w3-blue w3-round w3-round-top-large"> <header class="w3-container w3-blue w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3> <h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
@@ -161,3 +160,18 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -1,233 +0,0 @@
{% extends "planarian/base.html" %}
{% load i18n %}
{% block content %}
<div class="w3-container w3-padding" style="max-width:760px; margin:auto;">
{% if current_session and current_experiment %}
<!-- En-tête -->
<div class="w3-panel w3-blue w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
📥 {% trans "Exporter les données vers CSV" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="/"
class="w3-button w3-white w3-round w3-text-blue">
← {% trans "Retour" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Explication -->
<div class="w3-panel w3-pale-blue w3-round w3-margin-bottom">
<p>
{% 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." %}
</p>
<p class="w3-small w3-text-grey" style="margin:0;">
{% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %}
</p>
</div>
<!-- Formulaire -->
<form method="post" novalidate>
{% csrf_token %}
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
{% if form.non_field_errors %}
<div class="w3-panel w3-red w3-round">
{% for error in form.non_field_errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="w3-card-4 w3-border w3-round w3-round-large">
<header class="w3-container w3-blue w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<!-- Ligne 1 : expérience + puits -->
<div class="w3-row-padding w3-margin-bottom">
<div class="w3-col m7 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.experiment.label }}</b></label>
{% if form.experiment.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
{% endif %}
{{ form.experiment }}
{% if form.experiment.errors %}
<!--span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span-->
{% endif %}
</div>
<div class="w3-col m5 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.well.label }}</b></label>
{{ form.well }}
{% if form.well.errors %}
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
{% endif %}
</div>
</div>
<!-- Ligne 2 : planaire + type d'enregistrement -->
<div class="w3-row-padding w3-margin-bottom">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.planarian.label }}</b></label>
<p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
{% trans "Index du planaire dans le puits (commence à 0)" %}
</p>
{{ form.planarian }}
{% if form.planarian.errors %}
<span class="w3-text-red w3-small">{{ form.planarian.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m8 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.record_type.label }}</b></label>
<p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
{% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %}
</p>
<!-- Radio buttons stylisés W3 -->
<div class="w3-row-padding" style="margin-top:6px;">
{{ form.record_type }}
</div>
{% if form.record_type.errors %}
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
{% endif %}
</div>
</div>
<!-- Ligne 3 : plage temporelle (optionnel) -->
<div class="w3-border-top w3-padding-top w3-margin-top">
<p class="w3-text-blue-grey">
<b>{% trans "Plage temporelle" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "(optionnel — laisser vide pour exporter toute la session)" %}
</span>
</p>
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-blue-grey">{{ form.start_dt.label }}</label>
{{ form.start_dt }}
{% if form.start_dt.errors %}
<span class="w3-text-red w3-small">{{ form.start_dt.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-blue-grey">{{ form.stop_dt.label }}</label>
{{ form.stop_dt }}
{% if form.stop_dt.errors %}
<span class="w3-text-red w3-small">{{ form.stop_dt.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div><!-- fin padding -->
</div><!-- fin card -->
<!-- Boutons -->
<div class="w3-row-padding w3-margin-top">
<div class="w3-col s12">
<button type="submit" class="w3-button w3-blue w3-large w3-padding-large">
📥 {% trans "Générer et télécharger le CSV" %}
</button>
<a href="/"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Annuler" %}
</a>
</div>
</div>
</form>
<!-- Rappel des colonnes exportées -->
<div class="w3-card w3-round-large w3-margin-top w3-border w3-round w3-round-large w3-margin-bottom">
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
<h4 class="w3-text-white" style="margin:8px 0;">
{% trans "Colonnes du fichier CSV exporté" %}
</h4>
</header>
<div class="w3-container w3-padding-16">
<div class="w3-row-padding">
<div class="w3-col m6 s12">
<p class="w3-text-blue-grey w3-small"><b>{% trans "Identification / position" %}</b></p>
<ul class="w3-ul w3-small w3-border w3-round">
<li><code>timestamp</code></li>
<li><code>x_mm</code> / <code>y_mm</code></li>
<li><code>cx_px</code> / <code>cy_px</code></li>
<li><code>area_px</code></li>
<li><code>axial_pos</code> / <code>axial_speed</code></li>
</ul>
</div>
<div class="w3-col m6 s12">
<p class="w3-text-blue-grey w3-small"><b>{% trans "Métriques EthoVision XT" %}</b></p>
<ul class="w3-ul w3-small w3-border w3-round">
<li><code>velocity_mm_s</code> — {% trans "vitesse instantanée" %}</li>
<li><code>total_distance_mm</code> — {% trans "distance cumulée" %}</li>
<li><code>moving</code> / <code>duration_moving_s</code></li>
<li><code>mobility_state</code> — {% trans "Immobile / Mobile / Highly mobile" %}</li>
<li><code>dist_to_wall_mm</code> / <code>near_wall</code></li>
</ul>
</div>
</div>
</div>
</div>
</div><!-- fin container -->
<style>
input[type="text"],
input[type="number"],
input[type="datetime-local"],
select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 15px;
box-sizing: border-box;
background-color: #fafafa;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: #2196F3;
outline: none;
background-color: #fff;
}
/* Masquer le radio natif, laisser le label stylisé */
.w3-button input[type="radio"] {
display: none;
}
</style>
{% endif %}
{% endblock %}
@@ -3,52 +3,23 @@
{% block content %} {% block content %}
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;"> <div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
{% if current_session and current_experiment %}
<!-- En-tête --> {% include "inc/alert.html" %}
<div class="w3-panel w3-blue-grey w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
📂 {% trans "Importer des configurations depuis CSV" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="{% url 'planarian:experiment-list' %}"
class="w3-button w3-white w3-round w3-text-blue-grey">
← {% trans "Retour à la liste" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Formulaire d'import --> <!-- Formulaire d'import -->
<form method="post" enctype="multipart/form-data" novalidate id="import-form"> <form method="post" enctype="multipart/form-data" novalidate id="import-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
{% if form.non_field_errors %} <input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
<div class="w3-panel w3-red w3-round">
{% for error in form.non_field_errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="w3-card-4 w3-margin-bottom w3-border w3-round w3-round-large"> <div class="w3-card-4 w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-blue-grey w3-round w3-round-top-large"> <header class="w3-container w3-blue-grey w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}</h3> <h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}: <span class="w3-large w3-text-lime">{{ current_experiment }}</span></h3>
</header> </header>
<div class="w3-container w3-padding-24"> <div class="w3-container w3-padding-24">
<p class="w3-light-blue w3-padding w3-border w3-round" style="margin:0 0 4px;">
{% trans "Colonnes obligatoires : well, px_per_mm, fps." %}<br>
{% trans "Toutes les autres colonnes sont optionnelles." %}
<!-- Zone de dépôt de fichier --> <!-- Zone de dépôt de fichier -->
<div id="drop-zone" <div id="drop-zone"
@@ -67,22 +38,12 @@
{% trans "ou cliquez pour sélectionner" %} {% trans "ou cliquez pour sélectionner" %}
</span> </span>
</p> </p>
<!-- Input fichier caché --> <!-- Input fichier caché -->
<input type="file" id="id_csv_file" name="csv_file" <input type="file" id="id_csv_file" name="csv_file"
accept=".csv,text/csv" accept=".csv,text/csv"
style="display:none;" style="display:none;"
onchange="fileSelected(this)"> onchange="fileSelected(this)">
</div> </div>
{% if form.csv_file.errors %}
<div class="w3-panel w3-red w3-round w3-margin-top">
{% for error in form.csv_file.errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<!-- Aperçu du fichier sélectionné --> <!-- Aperçu du fichier sélectionné -->
<div id="file-info" class="w3-panel w3-pale-green w3-round w3-margin-top" style="display:none;"> <div id="file-info" class="w3-panel w3-pale-green w3-round w3-margin-top" style="display:none;">
<p id="file-name" class="w3-text-green"><b></b></p> <p id="file-name" class="w3-text-green"><b></b></p>
@@ -92,39 +53,32 @@
<!-- Option : écraser --> <!-- Option : écraser -->
<div class="w3-margin-top w3-padding-top w3-border-top"> <div class="w3-margin-top w3-padding-top w3-border-top">
<label class="w3-text-blue-grey" style="cursor:pointer; display:flex; align-items:center; gap:10px;"> <label class="w3-text-blue-grey" style="cursor:pointer; display:flex; align-items:center; gap:10px;">
{{ form.overwrite }} <input type="checkbox" name="overwriteoverwrite" class="w3-check" value="1" />
<span> <span>
<b>{{ form.overwrite.label }}</b><br> <b>{% trans "Écraser les configurations existantes" %}</b><br>
<span class="w3-small w3-text-grey"> <span class="w3-small w3-text-grey">
{% trans "Si décoché, les configurations déjà existantes (même experiment + well) seront ignorées." %} {% trans "Si décoché, les configurations déjà existantes (même experiment + well) seront ignorées." %}
</span> </span>
</span> </span>
</label> </label>
{% if form.overwrite.errors %}
<span class="w3-text-red w3-small">{{ form.overwrite.errors|join:", " }}</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Boutons --> <!-- Boutons -->
<div class="w3-row-padding w3-margin-bottom"> <div class="w3-row-padding w3-margin-bottom">
<div class="w3-col s12"> <div class="w3-col s12">
<button type="submit" id="submit-btn" <button type="submit" id="submit-btn" name="valid" value="ok"
class="w3-button w3-blue-grey w3-round w3-large w3-padding-large" class="w3-button w3-blue-grey w3-round w3-large w3-padding-large"
disabled> disabled>
📂 {% trans "Importer" %} 📂 {% trans "Importer" %}
</button> </button>
<a href="{% url 'planarian:experiment-list' %}" <a href="/"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left"> class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Annuler" %} ✖ {% trans "Annuler" %}
</a> </a>
</div> </div>
</div> </div>
</form> </form>
<!-- ============================================================ <!-- ============================================================
Format attendu du CSV Format attendu du CSV
============================================================ --> ============================================================ -->
@@ -214,18 +168,6 @@ exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br>
exp_light_01,B1,26.25,10,0.2,1.5,fixed<br> exp_light_01,B1,26.25,10,0.2,1.5,fixed<br>
exp_light_01,B2,26.25,10,0.2,1.5,fixed<br> exp_light_01,B2,26.25,10,0.2,1.5,fixed<br>
</div> </div>
<!-- Téléchargement du template -->
<div class="w3-margin-top">
<a href="{% url 'planarian:experiment-list' %}?export_template=1"
class="w3-button w3-teal w3-round w3-small">
⬇ {% trans "Télécharger un template CSV vide" %}
</a>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Ou exportez vos configurations existantes depuis l'admin Django." %}
</span>
</div>
</div> </div>
</div> </div>
@@ -321,5 +263,9 @@ exp_light_01,B2,26.25,10,0.2,1.5,fixed<br>
return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1048576).toFixed(1) + ' MB';
} }
</script> </script>
{% else %}
<div class="w3-container w3-padding-64 w3-margin" style="max-width:760px; margin:auto;">
<h3 class="w3-panel w3-blue w3-round-xlarge w3-padding-64 w3-center"> {% trans "Choisir une session" %}<br>{% trans "Puis une expérience." %}</h3>
</div>
{% endif %}
{% endblock %} {% endblock %}
+5 -19
View File
@@ -7,27 +7,13 @@ app_name = "planarian"
urlpatterns = [ urlpatterns = [
# Configurations expériences # Configurations expériences
#path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list2"),
#
#path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"),
#path("experiments/<int:pk>/",views.ExperimentConfigFormView.as_view(), name="experiment-edit2"),
#path("config/list/", views.config_list_view, name="experiment-list"),
#path("config/list/<int:session_id>/<int:experiment_id>/", views.config_list_view, name="experiment-list"),
#path("config/edit/<int:pk>/", views.config_edit_view, name="experiment-edit"),
#path("config/edit/<int:pk>/<int:session_id>/<int:experiment_id>/", views.config_edit_view, name="experiment-edit"),
# Import / export # Import / export
#path("import/", views.ImportParamsView.as_view(), name="import-params"), path("import/csv/", views.import_csv_view, name="import-params"),
#path("export/", views.ExportCsvView.as_view(), name="export-csv-view"), path("export/csv/", views.export_csv_view, name="export-csv"),
path("api/export/csv/", views.export_metrics, name="api-export-csv"),
#path("import/", views.ImportParamsView.as_view(), name="import-params"),
path("export/csv/", views.export_csv_view, name="export-csv"),
path("export/csv/<int:session_id>/<int:experiment_id>/", views.export_csv_view, name="export-csv"),
# API JSON pour le front-end # 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"),
] ]
+109 -94
View File
@@ -2,30 +2,27 @@
#import asyncio #import asyncio
import logging import logging
import csv
import io
import json
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponse, JsonResponse from django.http import JsonResponse, FileResponse
from django.shortcuts import get_object_or_404, redirect #, render from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _ 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.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 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.planarian_metrics import ExperimentParams, ReductStoreClient
from modules.system_stats import get_cached_stats, start_background_updater from modules.system_stats import get_cached_stats, start_background_updater
from scanner.constants import ScannerConstants from scanner.constants import ScannerConstants
from .tasks import export_experiment_metrics, export_session_metrics
from scanner import models from scanner import models
from .models import ExperimentConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,6 +58,7 @@ def global_context(request, **ctx):
domain_server=settings.DOMAIN_SERVER, domain_server=settings.DOMAIN_SERVER,
local_ip_server=settings.LOCAL_IP_SERVER, local_ip_server=settings.LOCAL_IP_SERVER,
host_port=settings.SERVER_HOST_PORT, host_port=settings.SERVER_HOST_PORT,
export_csv_destination=settings.CSV_EXPORT_DIR,
conf=conf, conf=conf,
**ctx **ctx
) )
@@ -100,6 +98,26 @@ def get_active_session(request, session_id=None, experiment_id=None):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue :Export CSV depuis ReductStore # 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): def export_csv(request):
d = request.POST d = request.POST
@@ -116,7 +134,7 @@ def export_csv(request):
start = d.get("start_dt"), start = d.get("start_dt"),
stop = d.get("stop_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: except Exception as e:
logger.error(f"Erreur export CSV: {e}") logger.error(f"Erreur export CSV: {e}")
messages.error(request, _("Erreur lors de l'export CSV: %(error)s") % {"error": str(e)}) messages.error(request, _("Erreur lors de l'export CSV: %(error)s") % {"error": str(e)})
@@ -124,31 +142,17 @@ def export_csv(request):
return csv_content, n return csv_content, n
csv_content, n = _do_export() csv_content, n = _do_export()
logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}") 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 = ( filename = (
f"{d['experiment']}_{d['well']}_planaire{d['planarian']}" f"{d['experiment']}_{d['well']}-planaire{d['planarian']}"
f"_{d['record_type']}.csv" f"_{d['record_type']}.csv"
) )
response = HttpResponse(csv_content, content_type="text/csv; charset=utf-8") return csv_content, filename
response["Content-Disposition"] = f'attachment; filename="{filename}"'
messages.success(request, _("%(n)d lignes exportées.") % {"n": n})
return response
@login_required @login_required
def export_csv_view(request, session_id=None, experiment_id=None): def export_csv_view(request):
session_context = get_active_session(request, session_id, experiment_id) session_context = get_active_session(request)
if request.method == 'POST':
valid = request.POST.get('valid')
if valid == 'ok':
export_csv(request)
ctx = { ctx = {
'choice_title': _("Export vers un fichier CSV depuis ReductStore"), 'choice_title': _("Export vers un fichier CSV depuis ReductStore"),
'well': 'A1', 'well': 'A1',
@@ -156,91 +160,102 @@ def export_csv_view(request, session_id=None, experiment_id=None):
'record_type': 'frame', 'record_type': 'frame',
**session_context **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)) return render(request, "planarian/export_csv.html", context=global_context(request, **ctx))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue : import CSV de paramètres # 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 @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. Import de configurations d'expérience depuis un fichier CSV.
Une ligne CSV = un puits = un ExperimentConfig. 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. Toutes les autres colonnes correspondent aux champs du modèle.
""" """
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:
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:
try: try:
params = ExperimentParams.from_csv_row(row) f = request.FILES.get('csv_file')
d = params.to_dict() 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)
obj, is_new = ExperimentConfig.objects.get_or_create( required = {"well", "px_per_mm", "fps"}
experiment = d["experiment"], if rows:
well = d["well"], missing = required - set(rows[0].keys())
) if missing:
msg = _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
if is_new or overwrite: raise Exception(msg)
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
return import_csv(request, current_experiment, rows, overwrite)
except Exception as e: except Exception as e:
logger.warning(f"Ligne ignorée ({row}): {e}") messages.error(request, msg)
errors += 1 logger.error(msg)
ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context }
messages.success( return render(request, "planarian/import_params.html", context=global_context(request, **ctx))
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue API JSON : données de tracking (pour polling front-end) # Vue API JSON : données de tracking (pour polling front-end)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TrackingDataView(View): class TrackingDataView(View):
""" """
API JSON retournant les métriques de tracking d'un planaire. API JSON retournant les métriques de tracking d'un planaire.
+4 -3
View File
@@ -7,7 +7,6 @@ import json
from django_celery_beat.models import PeriodicTask, ClockedSchedule from django_celery_beat.models import PeriodicTask, ClockedSchedule
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.utils.text import slugify
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from django.contrib.auth.models import User 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}) ExperimentWell.objects.get_or_create(experiment=instance, well=wp.well, author=instance.author, defaults={'active':True})
ExperimentConfig.objects.get_or_create( ExperimentConfig.objects.get_or_create(
experiment=instance, experiment_key=instance,
well=wp.well, well=wp.well.name,
author=instance.author, author=instance.author,
experiment=instance.identifier,
defaults={ defaults={
'px_per_mm': wp.px_per_mm, 'px_per_mm': wp.px_per_mm,
'fps': ScannerConstants().get().video_frame_rate, 'fps': ScannerConstants().get().video_frame_rate,
'well_radius_mm': instance.multiwell.diameter / 2, 'well_radius_mm': instance.multiwell.diameter / 2,
} }
) )
+1
View File
@@ -276,3 +276,4 @@ def supervisor_restart_service(params):
logger.error(f"supervisor_restart_all_services error {e}") logger.error(f"supervisor_restart_all_services error {e}")
@@ -99,6 +99,11 @@
<i class="fa-solid fa-gear w3-text-purple w3-xlarge"></i> {% trans "Création de sessions d'expériences" %} <i class="fa-solid fa-gear w3-text-purple w3-xlarge"></i> {% trans "Création de sessions d'expériences" %}
</a> </a>
<hr class="w3-bar-item divider w3-grey"> <hr class="w3-bar-item divider w3-grey">
{% if conf.tracking %}
<a href="{% url 'planarian:import-params' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-file-import w3-text-lime w3-xlarge"></i> {% trans "Importer des configurations depuis CSV" %}
</a>
{% endif %}
<a href="{% url 'scanner:experiment_view' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:experiment_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gear w3-text-pink w3-xlarge"></i> {% trans "Gestions des expériences" %} <i class="fa-solid fa-gear w3-text-pink w3-xlarge"></i> {% trans "Gestions des expériences" %}
</a> </a>
@@ -110,15 +115,15 @@
</a> </a>
<hr class="w3-bar-item divider w3-grey w3-margin-bottom"> <hr class="w3-bar-item divider w3-grey w3-margin-bottom">
{% endif %} {% endif %}
{% if conf.tracking %}
<a href="{% url 'planarian:export-csv' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-vials w3-text-lime w3-xlarge"></i> {% trans "Export CSV des expériences" %}
</a>
{% endif %}
<a href="{% url 'scanner:scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %} <i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
</a> </a>
<div class="w3-bar-item w3-dark-xxlight">{% trans "Résultats " %}</div> <div class="w3-bar-item w3-dark-xxlight">{% trans "Résultats " %}</div>
{% if conf.tracking %}
<a href="{% url 'planarian:export-csv' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-file-export w3-text-lime w3-xlarge"></i> {% trans "Export CSV des expériences" %}
</a>
{% endif %}
<a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %} <i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %}
</a> </a>
@@ -136,7 +141,7 @@
<script> <script>
const stats_endpoint = "{% url 'scanner:api_stats' %}"; const stats_endpoint = "{% url 'scanner:api_stats' %}";
</script> </script>
<!--script src="/static/scanner/js/stats.js"></script--> <script src="/static/scanner/js/stats.js"></script>
{% endblock %} {% endblock %}