diff --git a/src/InSillyCloWeb/assemblies/admin.py b/src/InSillyCloWeb/assemblies/admin.py index 35d97a12c4ece73a8b39ab7cf78790e665bdd22f..00babc05bdf75ee4b6a5514a31ce817c8e08277d 100644 --- a/src/InSillyCloWeb/assemblies/admin.py +++ b/src/InSillyCloWeb/assemblies/admin.py @@ -49,3 +49,40 @@ class UserAdmin(auth_admin.UserAdmin): 'is_staff', 'is_superuser', ) + + +@admin.register(models.SimulatorJob) +class JobAdmin( + ViewOnSiteModelAdmin, +): + ordering = ("created_at",) + date_hierarchy = 'created_at' + list_display = ( + 'uuid', + 'status', + 'created_at', + 'updated_at', + ) + search_fields = ( + 'uuid', + 'name', + ) + actions = [ + "re_run_job", + "delete_id_media_is_missing", + ] + + @admin.action(description="Re-run task") + def re_run_job(self, request, queryset): + for job in queryset: + job.run_simulator() + + @admin.action(description="Delete selected jobs if run_dir is missing/empty") + def delete_id_media_is_missing(self, request, queryset): + for job in queryset: + if not job.job_dir.exists(): + job.delete() + + def delete_queryset(self, request, queryset): + for o in queryset: + o.delete() diff --git a/src/InSillyCloWeb/assemblies/forms.py b/src/InSillyCloWeb/assemblies/forms.py index bd3cd4ca24398f51f194cf43342011a90ed3bdaf..4234daeba1655fe447ad3250bf674298799fa441 100644 --- a/src/InSillyCloWeb/assemblies/forms.py +++ b/src/InSillyCloWeb/assemblies/forms.py @@ -1,11 +1,12 @@ +import insillyclo.main from crispy_forms import layout from crispy_forms.helper import FormHelper from django import forms -from django.forms import modelformset_factory +from django.forms import modelformset_factory, modelform_factory from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from assemblies.models import Assembly, InputPart +from assemblies.models import Assembly, InputPart, SimulatorJob SEP_CHOICES = ( (".", "Dot"), @@ -14,6 +15,74 @@ SEP_CHOICES = ( ) +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleFileField(forms.FileField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", MultipleFileInput()) + super().__init__(*args, **kwargs) + + def clean(self, data, initial=None): + single_file_clean = super().clean + if isinstance(data, (list, tuple)): + result = [single_file_clean(d, initial) for d in data] + else: + result = single_file_clean(data, initial) + return result + + +class SimulatorAssemblyTypeForm(forms.Form): + input_file = forms.FileField( + label="Load your assembly file", + required=True, + widget=forms.ClearableFileInput(attrs={'multiple': False}), + ) + + +class SimulatorDataForm(forms.Form): + sequence_file = forms.FileField( + label="Load your sequence file", + required=True, + widget=forms.ClearableFileInput(attrs={'multiple': False}), + ) + + db_ip_files = forms.FileField( + label="Load your db ip files", + required=True, + widget=forms.ClearableFileInput(attrs={'multiple': False}), + ) + + +class PCRModelForm(forms.ModelForm): + class Meta: + model = SimulatorJob + fields = ('pcr_pairs_str',) + + primers_file = forms.FileField( + required=False, + label=_('Primers file'), + help_text=_('A CSV file where the header is \"primerId;sequence\".\nNote that the separator is \";\".'), + ) + + def save(self, commit=True): + instance: SimulatorJob = super().save(commit=commit) + if not commit or self.cleaned_data['primers_file'] is None: + return instance + output_fp = instance.job_dir / insillyclo.main.DEFAULT_CONCENTRATIONS_FILENAME + with open(output_fp, 'wb') as fh: + for chunk in self.cleaned_data['primers_file'].chunks(): + fh.write(chunk) + return instance + + +RestrictionEnzymeModelForm = modelform_factory( + model=SimulatorJob, + fields=('restriction_enzyme_gel',), +) + + class AssemblyForm(forms.ModelForm): class Meta: model = Assembly diff --git a/src/InSillyCloWeb/assemblies/migrations/0007_simulatorjob.py b/src/InSillyCloWeb/assemblies/migrations/0007_simulatorjob.py new file mode 100644 index 0000000000000000000000000000000000000000..08e83cff557b86f7f0d119c54adaeb2efc28fadb --- /dev/null +++ b/src/InSillyCloWeb/assemblies/migrations/0007_simulatorjob.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.4 on 2025-04-17 08:17 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assemblies', '0006_alter_inputpart_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='SimulatorJob', + fields=[ + ( + 'uuid', + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ('name', models.CharField(blank=True, max_length=255, null=True)), + ( + 'status', + models.IntegerField( + choices=[ + (None, '(Unknown)'), + (0, 'New'), + (1, 'Queued'), + (5, 'Fetching'), + (10, 'Running'), + (15, 'Done'), + (16, 'Error'), + (17, 'Canceled'), + ], + default=0, + ), + ), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/src/InSillyCloWeb/assemblies/migrations/0008_simulatorjob_pcr_pairs_str_and_more.py b/src/InSillyCloWeb/assemblies/migrations/0008_simulatorjob_pcr_pairs_str_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..395250ba8ce2d8c821fd940da126bc9b93ddc137 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/migrations/0008_simulatorjob_pcr_pairs_str_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.8 on 2025-04-17 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assemblies', '0007_simulatorjob'), + ] + + operations = [ + migrations.AddField( + model_name='simulatorjob', + name='pcr_pairs_str', + field=models.TextField( + blank=True, + default='', + help_text='In a CSV-like format where you have one pair per line, the forward primer first, then the reverse primer.', + verbose_name='PCR Pairs to use', + ), + ), + migrations.AddField( + model_name='simulatorjob', + name='restriction_enzyme_gel', + field=models.TextField( + blank=True, + default='', + help_text='Enzyme used to produce digestion gel', + verbose_name='Restriction enzyme', + ), + ), + ] diff --git a/src/InSillyCloWeb/assemblies/models.py b/src/InSillyCloWeb/assemblies/models.py index d2dd6116506cb0d0dce7dceeb328b25080e56001..86de26538dc163613e27e3f1378a80a075c241ec 100644 --- a/src/InSillyCloWeb/assemblies/models.py +++ b/src/InSillyCloWeb/assemblies/models.py @@ -4,6 +4,11 @@ import re from io import BytesIO from tempfile import NamedTemporaryFile from typing import List, Tuple +import uuid +import os +import zipfile +from pathlib import Path +import shutil from django.contrib.auth import models as auth_models from django.contrib.sessions.models import Session @@ -11,10 +16,15 @@ from django.db import models from django.db.models.functions import Upper from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from .insillyclo_impl import InSillyCloDjangoMessageObserver + import insillyclo.data_source import insillyclo.models import insillyclo.template_generator +import insillyclo +import insillyclo.simulator class UserManager(auth_models.UserManager): @@ -231,3 +241,146 @@ class Assembly(models.Model): def get_absolute_url(self): return reverse("assemblies:assembly-detail", args=[self.id]) + + +class JobStatus(models.IntegerChoices): + NEW = 0, _("New") + QUEUED = 1, _("Queued") + FETCHING = 5, _("Fetching") + RUNNING = 10, _("Running") + DONE = 15, _("Done") + ERROR = 16, _("Error") + CANCELED = 17, _("Canceled") + + __empty__ = "(Unknown)" + + +class SimulatorJob(models.Model): + ######################################################################### + # Job attributs and parameters + ######################################################################### + uuid = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + unique=True, + ) + name = models.CharField( + max_length=255, + null=True, + blank=True, + ) + status = models.IntegerField(choices=JobStatus.choices, default=JobStatus.NEW) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + ######################################################################### + # InSillyClo simulator attributs and parameters + ######################################################################### + pcr_pairs_str = models.TextField( + default='', + blank=True, + verbose_name="PCR Pairs to use", + help_text="In a CSV-like format where you have one pair per line, " + "the forward primer first, then the reverse primer.", + ) + restriction_enzyme_gel = models.TextField( + default='', + blank=True, + verbose_name="Restriction enzyme", + help_text="Enzyme used to produce digestion gel", + ) + + ######################################################################### + # Function and methods + ######################################################################### + def save(self, *args, **kwargs): + self.job_dir.mkdir(parents=True, exist_ok=True) + + super().save(*args, **kwargs) + + def add_design_to_dir(self, design_file): + file_name = os.path.join(self.job_dir, 'design_file.xlsx') + + with open(file_name, 'wb+') as f: + for chunk in design_file.chunks(): + f.write(chunk) + + def add_gb_to_dir(self, gb_file): + if not os.path.isdir(self.genbank_dir): + os.mkdir(self.genbank_dir) + + with zipfile.ZipFile(gb_file, 'r') as zip: + for member in zip.namelist(): + if Path(member).suffix == '.gb' and not Path(member).stem.startswith('.'): + source = zip.open(member) + new_filename = os.path.join(self.genbank_dir, Path(member).stem + '.gb') + with open(new_filename, 'wb') as dest: + shutil.copyfileobj(source, dest) + + def add_db_ip_to_dir(self, db_ip_file): + if not os.path.isdir(self.dbip_dir): + os.mkdir(self.dbip_dir) + + with zipfile.ZipFile(db_ip_file, 'r') as zip: + for member in zip.namelist(): + if not Path(member).stem.startswith('.'): + source = zip.open(member) + new_filename = os.path.join(self.dbip_dir, Path(member).stem + Path(member).suffix) + with open(new_filename, 'wb') as dest: + shutil.copyfileobj(source, dest) + + def run_insillyclo(self, request): + return insillyclo.simulator.compute_all( + observer=InSillyCloDjangoMessageObserver( + request=request, + debug=False, + fail_on_error=False, + ), + input_template_filled=os.path.join(self.job_dir, 'design_file.xlsx'), + input_parts_files=self.db_ip_files, + gb_plasmids=self.genbank_files, + output_dir=Path(self.results_dir), + data_source=insillyclo.data_source.DataSourceHardCodedImplementation(), + ) + + @property + def db_ip_files(self): + return [os.path.join(self.dbip_dir, file) for file in os.listdir(self.dbip_dir)] + + @property + def genbank_files(self): + return [Path(os.path.join(self.genbank_dir, file)) for file in os.listdir(self.genbank_dir)] + + @property + def job_dir(self) -> pathlib.Path: + return pathlib.Path(settings.MEDIA_ROOT) / 'jobs' / str(self.uuid) + + @property + def uuid_short(self): + return str(self.uuid)[:8] + + @property + def genbank_dir(self): + return os.path.join(self.job_dir, 'genbank') + + @property + def dbip_dir(self): + return os.path.join(self.job_dir, 'db_ip') + + @property + def results_dir(self): + return os.path.join(self.job_dir, 'results') + + def delete(self, *args, **kwargs): + try: + shutil.rmtree(self.job_dir) + except OSError as e: + print("Error: %s - %s." % (e.filename, e.strerror)) + return super().delete(*args, **kwargs) + + def get_absolute_url(self): + return reverse("assemblies:simulator-detail", args=[self.uuid]) + + def __str__(self): + return f'{str(self.uuid)[:8]} - {str(self.created_at)}' diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/index.html b/src/InSillyCloWeb/assemblies/templates/assemblies/index.html index be1d4ae4b4090020fc827c38faa99c1fcf5b4f10..43b1a1e96bd19fe06b9b5567065f56ea302aeaa1 100644 --- a/src/InSillyCloWeb/assemblies/templates/assemblies/index.html +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/index.html @@ -29,7 +29,7 @@ {%trans "Assembly simulator subtitle"%} </div> <div class="ma-body"> - <a role="button" class="btn btn-auto"> + <a role="button" class="btn btn-auto" href="{% url 'assemblies:simulator-create' %}"> {%trans "Start assembly simulator"%} </a> </div> diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_results.html b/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_results.html new file mode 100644 index 0000000000000000000000000000000000000000..424f371cd0f8b4d1013b1429b15ca3f761941860 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_results.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load sstatic %} +{% load i18n %} + +{% block extra_js %} +{{block.super}} +{% if extra_js_file %} +<script src="{% sstatic extra_js_file %}"></script> +{% endif%} +{% for extra_js_file in extra_js_files %} +<script src="{% sstatic extra_js_file %}"></script> +{% endfor%} +{% endblock %} + +{% block extra_css %} +{{block.super}} +{% if extra_css_file %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endif%} +{% for extra_css_file in extra_css_files %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endfor%} +{% endblock %} + +{% block container_class %}{{block.super}} {{ custom_container_class }}{% endblock %} + +{% block title %}Results{% endblock %} +{% block page_title %}Results{% endblock %} + +{% block content %} +<div class="formset-container col-12 col-xs-12 {%if custom_css_width %}{{custom_css_width}}{%else%}{%if not medium_width%}col-sm-10 col-sm-offset-1 offset-sm-1 col-md-8 col-md-offset-2 offset-md-2 col-lg-6 col-lg-offset-3 offset-lg-3 col-xl-4 col-xl-offset-4 offset-xl-4 col-xxl-2 col-xxl-offset-5{%endif%}{%if medium_width%}col-md-10 col-md-offset-1 offset-md-1 col-lg-8 col-lg-offset-2 offset-lg-2 col-xl-6 col-xl-offset-3 offset-xl-3 col-xxl-4 col-xxl-offset-4 col-xxxl-2 col-xxxl-offset-5{%endif%}{%endif%}"> + {{ simulation.uuid }} +</div> +{% endblock content %} \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_summary.html b/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_summary.html new file mode 100644 index 0000000000000000000000000000000000000000..3ac70fb832f72832e498ac89271b6b47d9d3d269 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/simulator_summary.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load sstatic %} +{% load i18n %} + +{% block extra_js %} +{{block.super}} +{% if extra_js_file %} +<script src="{% sstatic extra_js_file %}"></script> +{% endif%} +{% for extra_js_file in extra_js_files %} +<script src="{% sstatic extra_js_file %}"></script> +{% endfor%} +{% endblock %} + +{% block extra_css %} +{{block.super}} +{% if extra_css_file %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endif%} +{% for extra_css_file in extra_css_files %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endfor%} +{% endblock %} + +{% block container_class %}{{block.super}} {{ custom_container_class }}{% endblock %} + +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ page_title|default:title }}{% endblock %} + +{% block content %} +<div class="formset-container col-12 col-xs-12 {%if custom_css_width %}{{custom_css_width}}{%else%}{%if not medium_width%}col-sm-10 col-sm-offset-1 offset-sm-1 col-md-8 col-md-offset-2 offset-md-2 col-lg-6 col-lg-offset-3 offset-lg-3 col-xl-4 col-xl-offset-4 offset-xl-4 col-xxl-2 col-xxl-offset-5{%endif%}{%if medium_width%}col-md-10 col-md-offset-1 offset-md-1 col-lg-8 col-lg-offset-2 offset-lg-2 col-xl-6 col-xl-offset-3 offset-xl-3 col-xxl-4 col-xxl-offset-4 col-xxxl-2 col-xxxl-offset-5{%endif%}{%endif%}"> + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ wizard.form.media }} + {{ wizard.management_form }} + + {%if form_title %} + <h3 class="card-header {{form_title_class}}"> + {{form_title}} + </h3> + {%endif%} + {%if step_message %} + <div class="card-body"> + {{ step_message|default:"" }} + </div> + {%endif%} + + <div class="row mb-4"> + <div class="col-6"> + <div class="card"> + <div class="card-header">Input plasmid</div> + <table class="table table-striped"> + </table> + </div> + </div> + + <div class="col-6"> + <div class="card"> + <div class="card-header">Plasmid repository</div> + <table class="table table-striped"> + </table> + </div> + </div> + </div> + + <div class="card"> + <div class="card-header">Output plasmid</div> + <table class="table table-striped"> + <thead> + <tr> + <th scope="col">pID</th> + <th scope="col">Output part type</th> + <th scope="col">Input plasmid 1</th> + <th scope="col">Input plasmid 2</th> + <th scope="col">Input plasmid 3</th> + <th scope="col">Input plasmid 4</th> + <th scope="col">Input plasmid 5</th> + <th scope="col">Input plasmid 6</th> + <th scope="col">Input plasmid 7</th> + <th scope="col">Input plasmid 8</th> + </tr> + </thead> + <tbody> + {% for plasmid in plasmids %} + <tr> + <td>{{ plasmid.plasmid_id }}</td> + <td>{{ plasmid.output_type }}</td> + {% for part in plasmid.parts %} + <td colspan="{{ part.1.part_types|length }}">{{ part.0 }}</td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + </div> + + <div class="text-center mt-3"> + <a class="" role="button" href="../?reset"> + {% trans "clear form" %} + </a> + </div> + <button class=" btn btn-auto {{btn_classes}}" name="submit" type="submit"> + {{submit_text|default:"OK" }} + </button> + </form> +</div> +{% endblock content %} \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/simulatorjob_detail.html b/src/InSillyCloWeb/assemblies/templates/assemblies/simulatorjob_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..009b9939a5ded555bf6ef8b02a4e2d9eefef71f1 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/simulatorjob_detail.html @@ -0,0 +1,17 @@ +{% extends "assemblies/base.html" %} +{% load assemblies_tags %} +{% load i18n %} + + +{% block title %}Job: {{object.uuid_short}}{% endblock %} +{% block page_title %}Assembly simulator{% endblock %} +{% block page_sub_title %}Assembly simulator{% endblock %} + +{%block content %} +<h6 class="col-12 text-center"> + Run on {{object.updated_at}} +</h6> +<h1 class="col-12 text-center"> + TODO +</h1> +{%endblock content %} \ No newline at end of file diff --git a/src/InSillyCloWeb/assemblies/templates/assemblies/wizard_view_form_simulator_data.html b/src/InSillyCloWeb/assemblies/templates/assemblies/wizard_view_form_simulator_data.html new file mode 100644 index 0000000000000000000000000000000000000000..8ea79ca93a5360732135f5d71f9eaf7cbdca7cd2 --- /dev/null +++ b/src/InSillyCloWeb/assemblies/templates/assemblies/wizard_view_form_simulator_data.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% load crispy_forms_tags %} +{% load sstatic %} +{% load i18n %} + +{% block extra_js %} +{{block.super}} +{% if extra_js_file %} +<script src="{% sstatic extra_js_file %}"></script> +{% endif%} +{% for extra_js_file in extra_js_files %} +<script src="{% sstatic extra_js_file %}"></script> +{% endfor%} +{% endblock %} + +{% block extra_css %} +{{block.super}} +{% if extra_css_file %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endif%} +{% for extra_css_file in extra_css_files %} +<link rel="stylesheet" href="{% sstatic extra_css_file %}"/> +{% endfor%} +{% endblock %} + +{% block container_class %}{{block.super}} {{ custom_container_class }}{% endblock %} + +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ page_title|default:title }}{% endblock %} + +{% block content %} +<div class="formset-container col-12 col-xs-12 {%if custom_css_width %}{{custom_css_width}}{%else%}{%if not medium_width%}col-sm-10 col-sm-offset-1 offset-sm-1 col-md-8 col-md-offset-2 offset-md-2 col-lg-6 col-lg-offset-3 offset-lg-3 col-xl-4 col-xl-offset-4 offset-xl-4 col-xxl-2 col-xxl-offset-5{%endif%}{%if medium_width%}col-md-10 col-md-offset-1 offset-md-1 col-lg-8 col-lg-offset-2 offset-lg-2 col-xl-6 col-xl-offset-3 offset-xl-3 col-xxl-4 col-xxl-offset-4 col-xxxl-2 col-xxxl-offset-5{%endif%}{%endif%}"> + <form method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ wizard.form.media }} + {{ wizard.management_form }} + + {%if form_title %} + <h3 class="card-header {{form_title_class}}"> + {{form_title}} + </h3> + {%endif%} + {%if step_message %} + <div class="card-body"> + {{ step_message|default:"" }} + </div> + {%endif%} + + <div class="card card-auto mb-4"> + <div class="card-body"> + Name of the assembly: {{ assembly.name }} + <hr> + Separator: {{ assembly.separator }} + Enzyme: {{ assembly.enzyme }} + </div> + </div> + + {% if wizard.form.forms is not None %} + {{ wizard.form.management_form }} + <div class="card card-auto mb-4"> + <div class="card-body"> + {% for form in wizard.form.forms %} + <div class="formset-row" data-formset-prefix="{{ wizard.form.prefix }}"> + {% crispy form %} + {% if formset.can_delete %} + {{ form.DELETE }} + {% endif %} + </div> + {% endfor %} + {% if can_add_new != False %} + <div class="formset-new-item pb-4 text-center"> + <input type="button" + class="btn btn-auto" + value="{{add_new_item_to_formset_text}}"/> + </div> + {% endif %} + </div> + </div> + {% else %} + {% crispy wizard.form %} + {% endif %} + <div class="text-center mt-3"> + <a class="" role="button" href="../?reset"> + {% trans "clear form" %} + </a> + </div> + <div class="text-center mt-5"> + {% if wizard.steps.prev and not hide_step_button%} + {% if not hide_first_step_button%} + <a class="btn btn-secondary" role="button" href="../{{ wizard.steps.first }}"> + <i class="bi-chevron-double-left"></i> + <span class="d-none d-sm-inline">{% trans "first step" %}</span> + </a> + {% endif %} + <a class="btn btn-secondary" role="button" href="../{{ wizard.steps.prev }}"> + <i class="bi-chevron-left"></i> + <span class="d-none d-sm-inline">{% trans "prev step" %}</span> + </a> + {% endif %} + <button class=" btn btn-auto {{btn_classes}}" name="submit" type="submit"> + {{submit_text|default:"OK" }} + </button> + </div> + </form> + <div class="d-none empty_form" data-prefix="{{wizard.form.prefix}}"> + {% block emptyform %} + {% if wizard.form.forms is not None %} + <div class="formset-row" data-formset-prefix="{{ wizard.form.prefix }}"> + {% crispy wizard.form.empty_form %} + </div> + {% endif %} + {% endblock emptyform%} + </div> +</div> +{% endblock %} + diff --git a/src/InSillyCloWeb/assemblies/urls.py b/src/InSillyCloWeb/assemblies/urls.py index 4790a451d35c5cc602180f7d4185a8ab12ad9757..c35bd48462323108cd696c597131f6d945065663 100644 --- a/src/InSillyCloWeb/assemblies/urls.py +++ b/src/InSillyCloWeb/assemblies/urls.py @@ -7,6 +7,9 @@ designer_wizard = wizard_views.AssemblyDesignerNotProtected.as_view( url_name='assemblies:designer-create-step', ) +simulator_wizard = wizard_views.AssemblySimulatorNotProtected.as_view( + url_name='assemblies:simulator-create-step', +) designer_edition_wizard = wizard_views.AssemblyDesignerEdition.as_view( url_name='assemblies:designer-edit-step', ) @@ -62,6 +65,16 @@ urlpatterns = [ path('', views.index, name='assembly-simulator'), path('assembly/', views.AssemblyListView.as_view(), name='assembly-list'), path('assembly/<int:pk>/', views.AssemblyDetailView.as_view(), name='assembly-detail'), + path('assembly-simulator/create/<str:step>/', simulator_wizard, name='simulator-create-step'), + path('assembly-simulator/create/', simulator_wizard, name='simulator-create'), + path('assembly-simulator/<slug:uuid>/', views.JobSimulatorResult.as_view(), name='simulator-detail'), + path('assembly-simulator/<uuid:uuid>/results/', views.JobSimulatorResult.as_view(), name='simulator-results'), + path('assembly-simulator/<slug:uuid>/pcr-edit/', views.JobPCREdit.as_view(), name='simulator-pcr-edit'), + path( + 'assembly-simulator/<slug:uuid>/enzyme-edit/', + views.JobRestrictionEnzymeEdit.as_view(), + name='simulator-enzyme-edit', + ), path('assembly/<int:pk>/download/', views.AssemblyDetailDownloadView.as_view(), name='assembly-download'), path('assembly/<int:pk>/delete/', views.AssemblyDeleteView.as_view(), name='assembly-delete'), path('fragment', views.show_fragment, name='fragment'), diff --git a/src/InSillyCloWeb/assemblies/utils.py b/src/InSillyCloWeb/assemblies/utils.py index 3ca48c5857efda6fa98efc11cf0237f2005d1f07..b2c1d2a023ff4270977beb7f736ba961d9507e82 100644 --- a/src/InSillyCloWeb/assemblies/utils.py +++ b/src/InSillyCloWeb/assemblies/utils.py @@ -1,4 +1,5 @@ import os +import glob from zipfile import ZipFile from Bio import SeqIO from Bio.Seq import Seq @@ -56,3 +57,17 @@ def get_fragments(part, enzyme=None, site_for='GGTCTC', site_rev='GAGACC', inter (out_part, in_part) = get_in_out_part(reorder_seq(seq, site_for), site_rev) return get_sens_antisens(in_part, out_part, inter_s) + + +def get_files(path, extension, recursive=False): + """ + A generator of filepaths for each file into path with the target extension. + If recursive, it will loop over subfolders as well. + """ + if not recursive: + for file_path in glob.iglob(path + "/*." + extension): + yield file_path + else: + for root, dirs, files in os.walk(path): + for file_path in glob.iglob(root + "/*." + extension): + yield file_path diff --git a/src/InSillyCloWeb/assemblies/views.py b/src/InSillyCloWeb/assemblies/views.py index ce4c963440fc218d098ffebab0471638b31a8523..63f83a511ab6650a7dbd0dc0c69079bccb7ae867 100644 --- a/src/InSillyCloWeb/assemblies/views.py +++ b/src/InSillyCloWeb/assemblies/views.py @@ -1,13 +1,15 @@ import traceback +from abc import ABC, abstractmethod from io import BytesIO from typing import Tuple +import crispy_forms.helper from django.contrib import messages from django.http import HttpResponse -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ -from django.views.generic import ListView, DetailView, View, DeleteView +from django.views.generic import ListView, DetailView, View, DeleteView, UpdateView from django.views.generic.detail import SingleObjectMixin from . import mixins @@ -108,3 +110,64 @@ class AssemblyDeleteView( def form_valid(self, form): messages.info(self.request, _(f"Assembly {self.object} deleted")) return super().form_valid(form) + + +class JobSimulatorResult(DetailView): + model = models.SimulatorJob + slug_field = 'uuid' + slug_url_kwarg = 'uuid' + + template_name = "assemblies/simulator_results.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['simulation'] = self.object + return context + + +class SimulatorJobEdit( + ABC, + UpdateView, +): + model = models.SimulatorJob + slug_field = 'uuid' + slug_url_kwarg = 'uuid' + template_name = "assemblies/form_host.html" + + def get_form(self, form_class=None): + form = super().get_form() + if not hasattr(form, 'helper'): + form.helper = crispy_forms.helper.FormHelper() + form.helper.form_tag = False + return form + + def form_valid(self, form): + self.object.run_insillyclo(self.request) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = self.get_title() + return context + + @abstractmethod + def get_title(self): + pass + + +class JobPCREdit( + SimulatorJobEdit, +): + form_class = forms.PCRModelForm + + def get_title(self): + return _("PCR primers to use for gel simulation") + + +class JobRestrictionEnzymeEdit( + SimulatorJobEdit, +): + form_class = forms.RestrictionEnzymeModelForm + + def get_title(self): + return _("Restriction enzyme to use for gel simulation") diff --git a/src/InSillyCloWeb/assemblies/wizard_views.py b/src/InSillyCloWeb/assemblies/wizard_views.py index b65ec445d4cfae4e01e89339da93bb8f6ea5147e..88ef0daa38d5af1a36851c331c221726ec28ae34 100644 --- a/src/InSillyCloWeb/assemblies/wizard_views.py +++ b/src/InSillyCloWeb/assemblies/wizard_views.py @@ -1,15 +1,119 @@ +import insillyclo.parser from crispy_forms.helper import FormHelper from django.contrib.sessions.models import Session from django.db import transaction -from django.shortcuts import redirect +from django.shortcuts import render, redirect +from django.conf import settings from django.template.loader import render_to_string from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django.views.generic.detail import SingleObjectMixin from formtools.wizard import views as wizard_views +from django.core.files.storage import FileSystemStorage + +import pathlib + +import insillyclo +import insillyclo.simulator +import os from assemblies import forms, models, mixins +from .models import SimulatorJob +from .utils import get_files +from .insillyclo_impl import InSillyCloDjangoMessageObserver + + +class AssemblySimulatorNotProtected(wizard_views.NamedUrlSessionWizardView): + file_storage = FileSystemStorage(location=os.path.join(settings.MEDIA_ROOT, 'assemblytype')) + + form_list = [ + ("Intro", forms.AgreeForm), + ("InputFile", forms.SimulatorAssemblyTypeForm), + ("Data", forms.SimulatorDataForm), + ("Summary", forms.AgreeForm), + ] + + TEMPLATES = { + "Intro": 'wizard_view_form_host.html', + "InputFile": 'wizard_view_form_host.html', + "Data": 'assemblies/wizard_view_form_simulator_data.html', + "Summary": 'assemblies/simulator_summary.html', + } + + is_edition = False + condition_dict = dict() + + def get_context_data(self, form, step=None, **kwargs): + context = super().get_context_data(form=form, step=step, **kwargs) + context["submit_text"] = _("Next") + context["title"] = _("SimulatorDesignerWizard.title") + context["custom_css_width"] = "col-12 context-simulator" + context["form_title_class"] = "text-primary" + context["custom_container_class"] = "context-simulator" + context["hide_first_step_button"] = True + if step is None: + step = self.steps.current + if step == "Intro": + context["form_title_class"] = "h1" + context["title"] = "" + context["form_title"] = _("SimulatorDesignerWizard.Intro.form_title") + context["step_message"] = mark_safe(_("SimulatorDesignerWizard.Intro.step_message")) + elif step == "Data": + context['assembly'], context["plasmids"] = insillyclo.parser.parse_assembly_and_plasmid_from_template( + self.get_cleaned_data_for_step("InputFile")["input_file"], + input_part_factory=insillyclo.models.InputPartDataClassFactory(), + assembly_factory=insillyclo.models.AssemblyDataClassFactory(), + plasmid_factory=insillyclo.models.PlasmidDataClassFactory(), + observer=InSillyCloDjangoMessageObserver, + ) + elif step == "Summary": + context["submit_text"] = _("Run assembly") + context["form_title"] = _("SimulatorDesignerWizard.Summary.form_title") + + context['assembly'], context["plasmids"] = insillyclo.parser.parse_assembly_and_plasmid_from_template( + self.get_cleaned_data_for_step("InputFile")["input_file"], + input_part_factory=insillyclo.models.InputPartDataClassFactory(), + assembly_factory=insillyclo.models.AssemblyDataClassFactory(), + plasmid_factory=insillyclo.models.PlasmidDataClassFactory(), + observer=InSillyCloDjangoMessageObserver, + ) + return context + + def get_template_names(self): + return [self.TEMPLATES[self.steps.current]] + + def get_form_initial(self, step): + return super().get_form_initial(step) + + def get_form_kwargs(self, step=None): + d = super().get_form_kwargs(step=step) + return d + + def get_form(self, *args, **kwargs): + form = super().get_form(*args, **kwargs) + if not hasattr(form, 'helper'): + helper = FormHelper() + helper.form_tag = False + form.helper = helper + return form + + def done(self, form_list, form_dict=None, **kwargs): + + sjob = SimulatorJob.objects.create() + sjob.save() + + sjob.add_design_to_dir( + self.get_cleaned_data_for_step("InputFile")["input_file"], + ) + sjob.add_gb_to_dir( + self.get_cleaned_data_for_step("Data")["sequence_file"], + ) + sjob.add_db_ip_to_dir( + self.get_cleaned_data_for_step("Data")["db_ip_files"], + ) + + return redirect('assemblies:simulator-results', uuid=sjob.uuid) class AssemblyDesignerNotProtected(wizard_views.NamedUrlSessionWizardView): diff --git a/src/InSillyCloWeb/insillycloweb/settings.py b/src/InSillyCloWeb/insillycloweb/settings.py index cb7b56ba4b36153cdcc0f2c7e500e1745192e706..be6e99c3a26589c3abf7f6cdeb2c31f851c0613c 100644 --- a/src/InSillyCloWeb/insillycloweb/settings.py +++ b/src/InSillyCloWeb/insillycloweb/settings.py @@ -12,11 +12,9 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ import logging import os -import os -from pathlib import Path from pathlib import Path from socket import gethostname, gethostbyname -from socket import gethostname, gethostbyname + from decouple import config