This commit is contained in:
2026-05-09 13:07:42 +02:00
parent 5ba8e04ddf
commit c7caccd951
10 changed files with 75 additions and 38 deletions
+7 -6
View File
@@ -26,10 +26,10 @@ class ConfigurationAdmin(admin.ModelAdmin):
"classes": ("collapse",), "classes": ("collapse",),
}), }),
(_("Camera"), { (_("Camera"), {
"fields": ("capture_type", "webcam_device_index", "image_quality", "video_jpeg_quality", "video_frame_rate", "video_width_capture", "video_height_capture"), "fields": ("scan_simulation", "capture_type", "webcam_device_index", "image_quality", "video_jpeg_quality", "video_frame_rate", "video_width_capture", "video_height_capture"),
"classes": ("collapse",), "classes": ("collapse",),
}), }),
(_("Calibration"), { (_("Calibration / Balayage"), {
"fields": ("calibration_crop_radius", "calibration_default_multiwell", "calibration_default_feed", "calibration_default_step", "calibration_default_duration"), "fields": ("calibration_crop_radius", "calibration_default_multiwell", "calibration_default_feed", "calibration_default_step", "calibration_default_duration"),
"classes": ("collapse",), "classes": ("collapse",),
}), }),
@@ -64,13 +64,14 @@ class MultiWellAdmin(admin.ModelAdmin):
class WellPositionAdmin(admin.ModelAdmin): class WellPositionAdmin(admin.ModelAdmin):
list_filter = ('author', 'multiwell') list_filter = ('author', 'multiwell')
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',) list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',)
class ExperimentWellInline(admin.TabularInline): class ExperimentWellInline(admin.TabularInline):
model = models.ExperimentWell model = models.ExperimentWell
extra = 0 extra = 0
#ordering = ('experiment__multiwell__wellposition__order',)
class ExperimentAdmin(admin.ModelAdmin): class ExperimentAdmin(admin.ModelAdmin):
inlines = (ExperimentWellInline, ) inlines = (ExperimentWellInline, )
list_filter = ('session_experiments__session', 'author', ) list_filter = ('session_experiments__session', 'author', )
+1
View File
@@ -22,6 +22,7 @@ class DefaultConfig:
video_frame_rate: int = 5.0 video_frame_rate: int = 5.0
video_width_capture: int = 2028 video_width_capture: int = 2028
video_height_capture: int = 1520 video_height_capture: int = 1520
scan_simulation: bool = False
calibration_crop_radius: int = 500 calibration_crop_radius: int = 500
calibration_default_multiwell: str = 'HD' calibration_default_multiwell: str = 'HD'
calibration_default_feed: int = 1000 calibration_default_feed: int = 1000
+4 -3
View File
@@ -64,6 +64,7 @@ class Configuration(models.Model):
video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280) video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280)
video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720) video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720)
# Calibration # Calibration
scan_simulation = models.BooleanField(_("Simuler balayage"), help_text=_("Autorise la simulation du balayage"), default=False)
calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150) calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150)
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG') calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000) calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
@@ -308,7 +309,7 @@ class Session(models.Model):
def __str__(self): def __str__(self):
state = _("Terminée") if not self.active else _("Active") state = _("Terminée") if not self.active else _("Active")
return f'{self.name}: {state}' return f'{self.id}: {self.name} {state}'
@receiver(post_save, sender=Session) @receiver(post_save, sender=Session)
@@ -409,10 +410,10 @@ class ExperimentWell(models.Model):
@classmethod @classmethod
def wellname_by_experiment(cls, experiment_id): def wellname_by_experiment(cls, experiment_id):
return [ ew.well.name for ew in ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__order') ] return [ ew.well.name for ew in ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__name') ]
class Meta: class Meta:
ordering = ['experiment',] ordering = ['experiment', 'well']
unique_together = ["experiment", "well", ] unique_together = ["experiment", "well", ]
verbose_name = _("Expérience puit") verbose_name = _("Expérience puit")
verbose_name_plural = _("Expériences puitd") verbose_name_plural = _("Expériences puitd")
+17 -11
View File
@@ -78,8 +78,7 @@ class MultiWellManager:
def __init__(self, process): def __init__(self, process):
self.process = process self.process = process
self.cnc_controller = process.grbl self.cnc_controller = process.grbl
logger.info(f"MultiWellManager initialized with CNC controller: {self.cnc_controller}") logger.info(f"MultiWellManager initialized with CNC controller: {self.cnc_controller}")
self.stop_playing = Event() self.stop_playing = Event()
@@ -99,8 +98,6 @@ class MultiWellManager:
min_contour_dist_px = self.process.conf.min_contour_dist_px, min_contour_dist_px = self.process.conf.min_contour_dist_px,
) )
def set_tracker_config(self): def set_tracker_config(self):
self.tracker_config = dict( self.tracker_config = dict(
tube_axis = settings.TRACKER_TUBE_AXIS, tube_axis = settings.TRACKER_TUBE_AXIS,
@@ -169,6 +166,8 @@ class MultiWellManager:
self.process.data.record = True self.process.data.record = True
self.process._send(current=well_position.order) self.process._send(current=well_position.order)
logger.info(f"Starting capture for {uuid} ordre: {well_position.order}")
start = time.monotonic() start = time.monotonic()
while not self.stop_playing.is_set(): while not self.stop_playing.is_set():
## stop after duration in experiemnt now ## stop after duration in experiemnt now
@@ -196,8 +195,8 @@ class MultiWellManager:
logger.info(f"Simulating capture with file {vf}") logger.info(f"Simulating capture with file {vf}")
def _is_well_valid(self, welposition): def _is_well_valid(self, welposition, experiment):
names = models.ExperimentWell.wellname_by_experiment(welposition.experiment.id) names = models.ExperimentWell.wellname_by_experiment(experiment.id)
if welposition.well.name not in names: if welposition.well.name not in names:
return False return False
return True return True
@@ -206,13 +205,14 @@ class MultiWellManager:
try: try:
multiwell = experiment.multiwell multiwell = experiment.multiwell
wellpositions = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() wellpositions = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
cam = self.process.cam cam = self.process.cam
cam._aligner.set_tube_diameter(multiwell.diameter) cam._aligner.set_tube_diameter(multiwell.diameter)
for wl in wellpositions: for wl in wellpositions:
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
if not self._is_well_valid(wl): if not self._is_well_valid(wl, experiment):
continue continue
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
@@ -223,17 +223,18 @@ class MultiWellManager:
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})" msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
logger.info(msg) logger.info(msg)
self.process._send(state='scan_finished', msg=msg) self.process._send(state='scan_finished', msg=msg)
return True
except Exception as e: except Exception as e:
msg = f"Error during grid scanning - {e}" msg = f"Error during grid scanning - {e}"
logger.error(msg) logger.error(msg)
self.process._send(state='error', msg=msg) self.process._send(state='error', msg=msg)
return False
finally: finally:
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2) self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, experiments, simulate=False): def _start_scanning(self, session, experiments, simulate=False):
result = False
try: try:
self.process.cam._aligner.debug = False self.process.cam._aligner.debug = False
self.stop_playing.clear() self.stop_playing.clear()
@@ -247,18 +248,21 @@ class MultiWellManager:
self.process.data.session = session.id self.process.data.session = session.id
started = timezone.now() started = timezone.now()
for obs in experiments: for obs in experiments:
logger.warning(f"Starting scan for {obs} (well {pos}/{len(experiments)})")
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
obs.started = timezone.now() obs.started = timezone.now()
obs.save() obs.save()
xnext, ynext = xynext[pos] xnext, ynext = xynext[pos]
pos +=1 pos +=1
self._grid_scanning(obs, xnext=xnext, ynext=ynext, simulate=simulate) result = self._grid_scanning(obs, xnext=xnext, ynext=ynext, simulate=simulate)
obs.finished = timezone.now() obs.finished = timezone.now()
obs.save() obs.save()
session.finished = timezone.now() session.finished = timezone.now()
if self.stop_playing.is_set(): if self.stop_playing.is_set() or not result:
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes." msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
else: else:
if not simulate: if not simulate:
@@ -273,6 +277,8 @@ class MultiWellManager:
logger.error("Error during scanning process", e) logger.error("Error during scanning process", e)
finally: finally:
self.scan_thread = None self.scan_thread = None
self.goto_0()
self.process._send(current=self.get_well_order())
def halt_scanning(self): def halt_scanning(self):
+1 -1
View File
@@ -373,7 +373,7 @@ class ScannerProcess(Task):
self.cam._active_median = False self.cam._active_median = False
self.grbl.go_origin(feed=self.manager.feed) self.grbl.go_origin(feed=self.manager.feed)
self.cam.set_draw_contours(False) self.cam.set_draw_contours(False)
buttons = self.manager.multiwell_buttons(btn_class="w3-btn", onclick="") buttons = self.manager.multiwell_buttons(btn_class="w3-btn well", onclick="")
self._send(buttons=buttons) self._send(buttons=buttons)
elif topic == 'scan' or topic == 'simulate': elif topic == 'scan' or topic == 'simulate':
@@ -23,7 +23,7 @@ class ScannerManager {
this.area_px = options.area_px; this.area_px = options.area_px;
this.frame_count = options.frame_count; this.frame_count = options.frame_count;
this.scan_state = options.scan_state; this.scan_state = options.scan_state;
this.sim_bt = options.simulate; this.sim_bt = options.simulate || null;
this.well_btn = options.well_btn; this.well_btn = options.well_btn;
} }
@@ -32,7 +32,9 @@ class ScannerManager {
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); }); this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.scan_bt.addEventListener('click', (e) => { this.scan(); }); this.scan_bt.addEventListener('click', (e) => { this.scan(); });
this.halt_bt.addEventListener('click', (e) => { this.halt(); }); this.halt_bt.addEventListener('click', (e) => { this.halt(); });
this.sim_bt.addEventListener('click', (e) => { this.simulate(); });
if (this.sim_bt)
this.sim_bt.addEventListener('click', (e) => { this.simulate(); });
} }
registerSocket(socket) { registerSocket(socket) {
@@ -44,18 +46,29 @@ class ScannerManager {
try { try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; } if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); } if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); } if (payload.state) {
if (payload.state == 'median') {
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
} else if (payload.state == 'crop') {
const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff';
}
this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`);
}
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;} if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;}
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; } if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons;
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
}
if (payload.current >= 0) { if (payload.current >= 0) {
document.querySelectorAll('button.w3-button.well').forEach(btn => { document.querySelectorAll('button.w3-btn.well').forEach(btn => {
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; } if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
btn.classList.remove('w3-green'); btn.classList.remove('w3-green');
}); });
} }
} catch(e) { console.log(e); } } catch(e) { console.log(e); }
} }
@@ -12,7 +12,7 @@
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()" title="{% trans "Ensemble d'expériences" %}"> <select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()" title="{% trans "Ensemble d'expériences" %}">
<option value="0">---- {% trans "Session" %}</option> <option value="0">---- {% trans "Session" %}</option>
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s.name }}</option> <option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="w3-margin-left w3-margin-bottom"> <div class="w3-margin-left w3-margin-bottom">
@@ -13,7 +13,7 @@
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()"> <select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
<option value="0">---- {% trans "Session" %}</option> <option value="0">---- {% trans "Session" %}</option>
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s.name }}</option> <option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="w3-margin-left w3-margin-bottom"> <div class="w3-margin-left w3-margin-bottom">
@@ -14,7 +14,7 @@
<div>{% trans "Choix de la session" %}</div> <div>{% trans "Choix de la session" %}</div>
<select id="_session" class="w3-select"> <select id="_session" class="w3-select">
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}">{{ s.name }}</option> <option value="{{ s.id }}">{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -28,26 +28,28 @@
<div class="w3-row w3-row-padding"> <div class="w3-row w3-row-padding">
<div class="w3-half"> <div class="w3-half">
<button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-block"> <button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-block">
{% trans 'Axes' %}&nbsp;<br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i> <span class="median">{% trans 'Axes' %}</span><br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i>
</button> </button>
</div> </div>
<div class="w3-half"> <div class="w3-half">
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-block"> <button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-block">
{% trans 'Recadrer' %}<br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i> <span class="crop" style="color: red">{% trans 'Recadrer' %}</span><br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="w3-row w3-row-padding"> <div class="w3-row w3-row-padding">
<div class="w3-twothird w3-center"> <div class="{% if conf.scan_simulation %}w3-twothird{% else %}w3-col{% endif %} w3-center">
<button id="_scan" class="w3-button w3-light-blue w3-round-large w3-margin-top w3-block"> <button id="_scan" class="w3-button w3-light-blue w3-round-large w3-margin-top w3-block">
{% trans 'Lancer' %}<br>{% trans 'le balayage' %}<br><i class="fa-solid fa-circle-check w3-text-blue w3-xlarge"></i> <span class="w3-bold">{% trans 'Lancer' %}<br>{% trans 'le balayage' %}</span><br><i class="fa-solid fa-circle-check w3-text-blue w3-xlarge"></i>
</button> </button>
</div> </div>
{% if conf.scan_simulation %}
<div class="w3-third w3-center"> <div class="w3-third w3-center">
<button id="_simulate" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block"> <button id="_simulate" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block">
{% trans 'Simuler' %}<br>{% trans 'le balayage' %}<br><i class="fa-solid fa-triangle-exclamation w3-text-orange w3-xlarge"></i> <span class="w3-bold">{% trans 'Simuler' %}<br>{% trans 'le balayage' %}</span><br><i class="fa-solid fa-triangle-exclamation w3-text-orange w3-xlarge"></i>
</button> </button>
</div> </div>
{% endif %}
<div class="w3-col"> <div class="w3-col">
<div id="_well_btn" class="w3-margin-top"></div> <div id="_well_btn" class="w3-margin-top"></div>
</div> </div>
@@ -58,10 +60,10 @@
</div> </div>
</div> </div>
<div class="w3-col"> <div class="w3-col">
<div class="w3-margin-top w3-padding w3-small"> <div class="w3-margin-top w3-padding-small w3-small">
<span id="_ts"></span> <span id="_ts"></span>
</div> </div>
<div class="w3-margin-top w3-small"> <div class="w3-margin-top w3-small w3-center w3-justify">
<span id="_scan_state" class="w3-text-amber w3-padding"></span> <span id="_scan_state" class="w3-text-amber w3-padding"></span>
</div> </div>
</div> </div>
+14 -1
View File
@@ -6,7 +6,7 @@ from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST 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 from django.contrib.auth.decorators import login_required, user_passes_test
from django.conf import settings from django.conf import settings
from reduct.time import unix_timestamp_to_iso from reduct.time import unix_timestamp_to_iso
from modules.system_stats import get_cached_stats, start_background_updater from modules.system_stats import get_cached_stats, start_background_updater
@@ -20,6 +20,10 @@ from .constants import ScannerConstants
record_manager = CameraRecordManager(cameraDB) record_manager = CameraRecordManager(cameraDB)
start_background_updater() start_background_updater()
def is_staff_or_admin(user):
return user.is_staff or user.is_superuser
@require_GET @require_GET
def stats_view(request): def stats_view(request):
@@ -48,10 +52,12 @@ def global_context(request, **ctx):
) )
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def admin_view(request): def admin_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/')) return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/'))
@login_required @login_required
def admin_session_view(request): def admin_session_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/session/')) return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/session/'))
@@ -68,26 +74,33 @@ def admin_periodictask_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/django_celery_beat/periodictask/')) return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/django_celery_beat/periodictask/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def reductstore_view(request): def reductstore_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:8383/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:8383/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def adminer_view(request): def adminer_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}/adminer/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}/adminer/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def portainer_view(request): def portainer_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9000/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9000/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_view(request): def supervisor_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_worker(request): def supervisor_worker(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_scheduler(request): def supervisor_scheduler(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:planification')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:planification'))